feat: fsck incoming objects (#12695)

Weirdly, git doesn't verify the consistency of objects when receiving
new objects. Enable that git verifies this, so we don't allow a
repository to get in a weird or even corrupt state.

We've already dealt with a few cases of inconsistent objects, the most
notable one being mode of objects (forgejo/forgejo!9161). This can be
risky, as such ignore 3 consistency checks that are not harmful to
ignore and is battle tested by Gitlab.

bad timezone:
692a0d3476

missing space:
2da0b39399

non-zero padded filemode:
db8f2e8da5

Typically we set these settings in `modules/git/git.go`, but that means
a instance administrator wouldn't be able to override it. Given we don't
strictly require these settings to be set. A instance admin could
choose to disable the consistency checks or override our set of ignores
this would allow them to do so via the `[git.config]` section.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12695
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Reviewed-by: elle <0xllx0@noreply.codeberg.org>
This commit is contained in:
Gusted 2026-05-25 14:51:04 +02:00 committed by Gusted
commit 385c0db94f
38 changed files with 121 additions and 60 deletions

View file

@ -95,6 +95,17 @@ func loadGitFrom(rootCfg ConfigProvider) {
GitConfig.SetOption("core.logAllRefUpdates", "true")
GitConfig.SetOption("gc.reflogExpire", "90")
GitConfig.SetOption("transfer.fsckObjects", "true")
// To ignore specific warnings they have to be set for all of the three
// scenarios. Per git-config(1): "To uniformly configure the same fsck
// settings in different circumstances, all three of them must be set to the
// same values."
for _, prefix := range []string{"fsck.", "fetch.fsck.", "receive.fsck."} {
GitConfig.SetOption(prefix+"badTimezone", "ignore")
GitConfig.SetOption(prefix+"missingSpaceBeforeDate", "ignore")
GitConfig.SetOption(prefix+"zeroPaddedFilemode", "ignore")
}
for _, key := range secGitConfig.Keys() {
GitConfig.SetOption(key.Name(), key.String())
}

View file

@ -32,6 +32,32 @@ diff.algorithm = other
require.NoError(t, err)
loadGitFrom(cfg)
assert.Equal(t, "other", GitConfig.Options["diff.algorithm"])
t.Run("Fsck options", func(t *testing.T) {
cfg, err := NewConfigProviderFromData(`
[git.config]
receive.fsckObjects = false
fetch.fsck.zeroPaddedFilemode = warn
fsck.missingSpaceBeforeDate = error
`)
require.NoError(t, err)
loadGitFrom(cfg)
assert.Equal(t, "false", GitConfig.Options["receive.fsckobjects"])
assert.Equal(t, "true", GitConfig.Options["transfer.fsckobjects"])
assert.NotContains(t, GitConfig.Options, "fetch.fsckobjects")
assert.Equal(t, "ignore", GitConfig.Options["fsck.badtimezone"])
assert.Equal(t, "error", GitConfig.Options["fsck.missingspacebeforedate"])
assert.Equal(t, "ignore", GitConfig.Options["fsck.zeropaddedfilemode"])
assert.Equal(t, "ignore", GitConfig.Options["receive.fsck.badtimezone"])
assert.Equal(t, "ignore", GitConfig.Options["receive.fsck.missingspacebeforedate"])
assert.Equal(t, "ignore", GitConfig.Options["receive.fsck.zeropaddedfilemode"])
assert.Equal(t, "ignore", GitConfig.Options["fetch.fsck.badtimezone"])
assert.Equal(t, "ignore", GitConfig.Options["fetch.fsck.missingspacebeforedate"])
assert.Equal(t, "warn", GitConfig.Options["fetch.fsck.zeropaddedfilemode"])
})
}
func TestGitReflog(t *testing.T) {

View file

@ -86,7 +86,7 @@ func TestWiki(t *testing.T) {
Wiki(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.Status())
assert.EqualValues(t, "Home", ctx.Data["Title"])
assertPagesMetas(t, []string{"Home", "Long Page", "Page With Image", "Page With Spaced Name", "Unescaped File", "XSS"}, ctx.Data["Pages"])
assertPagesMetas(t, []string{"Home", "Long Page", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"])
}
func TestWikiPages(t *testing.T) {
@ -96,7 +96,7 @@ func TestWikiPages(t *testing.T) {
contexttest.LoadRepo(t, ctx, 1)
WikiPages(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.Status())
assertPagesMetas(t, []string{"Home", "Long Page", "Page With Image", "Page With Spaced Name", "Unescaped File", "XSS"}, ctx.Data["Pages"])
assertPagesMetas(t, []string{"Home", "Long Page", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"])
}
func TestNewWiki(t *testing.T) {

View file

@ -1 +0,0 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -0,0 +1 @@
d49ac742d44063dcf69d4e0afe725813b777dd89 refs/heads/master

View file

@ -1,2 +0,0 @@
x­ŽŃmÄ DóMŰŔY¬Í<1A>˘(ůJ©`<60>5ÇÉś-K*Ki,Hi!?ŁŃ<éiâVki0Z˙ÔXH“D(Z6ĨGňSb» 3“JDŢhµó!÷uB¬ĚDaJpˇ íśŮčFôLĆą4+~´ëvŔ;‡ŁČ
eýäžőç[Nx>KÝäÎü‡_sĺ˛q«/€]09MHpѤµękżÜä_dę-%¸í‡Űž<C5B0>ď vÎ_Ą]ˇÔ^Ő/čI[t

View file

@ -1 +0,0 @@
xÆM@ †á¯ àºré°6ñœ&&&¬ü9LežÅw½Ý×åt<#ÞñÃÍ¡ªmv-·•0w¬b¦¢jy̆¤Ú—~Ý‹[žæÉçý=HÄ ÷.¾"à‚íµÄçÇ<>=

View file

@ -1,2 +0,0 @@
x­ŽÝmÃ0ƒû¬)n<>ú±t2íSèçÓÙ`ņ¥¶“e,VY¡/Hâ#È[)¹<>EûÒ@NÈq¦è툎Ñr2«)Å0âŒj§C®ìÑLÂ<4C>ŸœaCÓÃ&š4B<34>v]$Eßí²ðIÓe…¯¼þP×r¿I…sÍe“zªË³~_
åõÄ[yã‡è¢v£<76>WíµV=í—ü˘úH vZ~s»@݉%Á•Ѝ?TÊZH

View file

@ -1 +0,0 @@
xťŹÍn@…»ž§¸ű*ń†áGjŞŚmJ±‰C1“ě~R0ŹÓ§i}*ďU·•şĘ*gw>éHçËş¶­5<C2AD>6}§Ą€3”)Sh˛´0 9ÍTƨáž;2i2Óĺ®-É^óLň4wSžŰŞp™mSTČ­Ű\9V†Ô1Sép"Źşę¸ŐĽ|ůůëÇ“„ťî.Z>É법us™uíG 63<36>Ąđ)"9ÓóG­Ţ¶.÷ĺˇ.áâO¦ž¬ ň#XţJ$›ŘűË ¨?o<1˝_Š)n<>/±~ćá6ąëŐcS­űl<ÔXŽiŇb3{@ź÷B,g±řĎ ” Ż‘ŰËK|˝ĆEĐ[ß0\ď<>ÉŹaWńݢRć|¤ři{şąžxd… ŤĂÉßô9<ĐŮŇŹšg>»¨Š KOvÉ"D§J„sEŕęŘśzňĎĆ[Í_s!2ϡév%ěe©Čo(˝”

View file

@ -0,0 +1,2 @@
P pack-dcd8abdfa327da7644d88ee8f092b02a7bb210d4.pack

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
d49ac742d44063dcf69d4e0afe725813b777dd89 refs/heads/master

View file

@ -1 +0,0 @@
f54f5a6b7c4f83b606600e43186165854f189530

View file

@ -223,29 +223,6 @@ func TestAPIListWikiPages(t *testing.T) {
Message: "add unescaped file\n",
},
},
{
Title: "XSS",
HTMLURL: meta[5].HTMLURL,
SubURL: "XSS",
LastCommit: &api.WikiCommit{
ID: "f54f5a6b7c4f83b606600e43186165854f189530",
Author: &api.CommitUser{
Identity: api.Identity{
Name: "Gusted<script class=\"evil\">alert('Oh no!');</script>",
Email: "valid@example.org",
},
Date: "2024-01-31T00:00:00Z",
},
Committer: &api.CommitUser{
Identity: api.Identity{
Name: "Gusted<script class=\"evil\">alert('Oh no!');</script>",
Email: "valid@example.org",
},
Date: "2024-01-31T00:00:00Z",
},
Message: "Yay XSS",
},
},
}
assert.Equal(t, dummymeta, meta)

View file

@ -121,6 +121,7 @@ func testGit(t *testing.T, u *url.URL) {
t.Run("PushForkRemoteMessages", doTestForkPushMessages(httpContext, dstPath))
t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "test/head"))
t.Run("InternalReferences", doInternalReferences(&httpContext, dstPath))
t.Run("FsckConsistencyChecks", doFsckConsistencyChecks(dstPath))
t.Run("BranchProtect", doBranchProtect(&httpContext, dstPath))
t.Run("AutoMerge", doAutoPRMerge(&httpContext, dstPath))
t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge"))
@ -170,6 +171,7 @@ func testGit(t *testing.T, u *url.URL) {
t.Run("PushForkRemoteMessages", doTestForkPushMessages(sshContext, dstPath))
t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "test/head2"))
t.Run("InternalReferences", doInternalReferences(&sshContext, dstPath))
t.Run("FsckConsistencyChecks", doFsckConsistencyChecks(dstPath))
t.Run("BranchProtect", doBranchProtect(&sshContext, dstPath))
t.Run("MergeFork", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
@ -1337,3 +1339,78 @@ func TestCloneAccessTokenResources(t *testing.T) {
})
})
}
func doFsckConsistencyChecks(dstPath string) func(t *testing.T) {
return func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
tree, _, err := git.NewCommand(git.DefaultContext, "rev-parse", "HEAD^{tree}").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
head, _, err := git.NewCommand(git.DefaultContext, "rev-parse", "HEAD^{commit}").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
// Is checked for and should error.
t.Run("badName", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
var buf bytes.Buffer
buf.WriteString("tree ")
buf.WriteString(tree)
buf.WriteString("parent ")
buf.WriteString(head)
buf.WriteString("author @>\n")
buf.WriteString("committer @>\n")
buf.WriteString("\n")
commitID, _, err := git.NewCommand(git.DefaultContext, "hash-object", "-t", "commit", "--literally", "--stdin", "-w").RunStdString(&git.RunOpts{Dir: dstPath, Stdin: &buf})
require.NoError(t, err)
commitID = strings.TrimSpace(commitID)
_, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments(commitID + ":refs/heads/consistency-check").RunStdString(&git.RunOpts{Dir: dstPath})
require.Error(t, gitErr)
assert.Contains(t, stdErr, "badName: invalid author/committer line - bad name")
})
t.Run("missingSpaceBeforeEmail", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
var buf bytes.Buffer
buf.WriteString("tree ")
buf.WriteString(tree)
buf.WriteString("parent ")
buf.WriteString(head)
buf.WriteString(`author Gusted<script class="evil">alert('Oh no!');</script> <valid@example.org> 1706659200 +0000`)
buf.WriteRune('\n')
buf.WriteString(`committer Gusted<script class="evil">alert('Oh no!');</script> <valid@example.org> 1706659200 +0000`)
buf.WriteString("\n\n")
commitID, _, err := git.NewCommand(git.DefaultContext, "hash-object", "-t", "commit", "--literally", "--stdin", "-w").RunStdString(&git.RunOpts{Dir: dstPath, Stdin: &buf})
require.NoError(t, err)
commitID = strings.TrimSpace(commitID)
_, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments(commitID + ":refs/heads/consistency-check").RunStdString(&git.RunOpts{Dir: dstPath})
require.Error(t, gitErr)
assert.Contains(t, stdErr, "missingSpaceBeforeEmail: invalid author/committer line - missing space before email")
})
// Is set to be ignored.
t.Run("badTimezone", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
var buf bytes.Buffer
buf.WriteString("tree ")
buf.WriteString(tree)
buf.WriteString("parent ")
buf.WriteString(head)
buf.WriteString("author name <email> 1234 +\n")
buf.WriteString("committer name <email> 1234 +\n")
buf.WriteString("\n")
commitID, _, err := git.NewCommand(git.DefaultContext, "hash-object", "-t", "commit", "--literally", "--stdin", "-w").RunStdString(&git.RunOpts{Dir: dstPath, Stdin: &buf})
require.NoError(t, err)
commitID = strings.TrimSpace(commitID)
require.NoError(t, git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments(commitID+":refs/heads/consistency-check").Run(&git.RunOpts{Dir: dstPath}))
})
}
}

View file

@ -39,33 +39,6 @@ func TestXSSUserFullName(t *testing.T) {
)
}
func TestXSSWikiLastCommitInfo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Check on page view.
t.Run("Page view", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, http.MethodGet, "/user2/repo1/wiki/XSS")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, "script.evil", false)
assert.Contains(t, htmlDoc.Find(".ui.sub.header").Text(), `Gusted<script class="evil">alert('Oh no!');</script> edited this page 2024-01-31`)
})
// Check on revisions page.
t.Run("Revision page", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, http.MethodGet, "/user2/repo1/wiki/XSS?action=_revision")
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertElement(t, "script.evil", false)
assert.Contains(t, htmlDoc.Find(".ui.sub.header").Text(), `Gusted<script class="evil">alert('Oh no!');</script> edited this page 2024-01-31`)
})
}
func TestXSSReviewDismissed(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestXSSReviewDismissed")()
defer tests.PrepareTestEnv(t)()