Santiago, As promised, signed debdiffs attached for both packages (both against the exact source currently in bookworm):
- cjose_0.6.2.1-1+deb12u1.debdiff.asc (CVE-2026-53938, CVE-2026-53939) - git-lfs_3.3.0-1+deb12u2.debdiff.asc (CVE-2025-26625, plus the two follow-up hardening commits) Both clearsigned with the key I use for Debian work (232C682F2067D6FF, [email protected] UID). Let me know if you'd like anything else from me to move these forward. Thanks, Ivo
-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 diff --git a/debian/changelog b/debian/changelog index 10cf61b..38bba78 100644 - --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,15 @@ +cjose (0.6.2.1-1+deb12u1) bookworm-security; urgency=high + + * Fix CVE-2026-53938: heap buffer overflow in AES key unwrap. A + crafted JWE with an oversized encrypted_key for an AES key wrap + algorithm (A128KW/A192KW/A256KW) triggered an out-of-bounds heap + write; remote and unauthenticated. + * Fix CVE-2026-53939: the AES-CBC-HMAC content-encryption key was + generated as all zero bytes instead of randomly, so every JWE + encrypted with an AES-CBC-HMAC enc algorithm used the same fixed, + publicly known key. + + -- Ivo Marino <[email protected]> Sun, 13 Sep 2026 16:21:00 +0000 cjose (0.6.2.1-1) unstable; urgency=medium [ Debian Janitor ] diff --git a/debian/patches/CVE-2026-53938.patch b/debian/patches/CVE-2026-53938.patch new file mode 100644 index 0000000..96fbccf - --- /dev/null +++ b/debian/patches/CVE-2026-53938.patch @@ -0,0 +1,33 @@ +Description: fix heap buffer overflow in AES key unwrap + _cjose_jwe_decrypt_ek_aes_kw() passed the attacker-controlled + encrypted_key straight to AES_unwrap_key() without checking its + length against the fixed-size jwe->cek buffer it unwraps into. A + crafted JWE with an oversized encrypted_key segment for an AES key + wrap algorithm (A128KW/A192KW/A256KW) triggers an out-of-bounds heap + write, which is at minimum a remote, unauthenticated denial of + service. Add the length check RFC 3394 already implies (wrapped key + length is always the plaintext CEK length plus 8 bytes) before + unwrapping. +Origin: upstream, https://github.com/OpenIDC/cjose/commit/8c51d2452 +Bug: https://github.com/OpenIDC/cjose/security/advisories +Forwarded: not-needed +Applied-Upstream: 0.6.2.5 +Last-Update: 2026-09-13 +--- a/src/jwe.c ++++ b/src/jwe.c +@@ -587,6 +587,15 @@ static bool _cjose_jwe_decrypt_ek_aes_kw(_jwe_int_recipient_t *recipient, cjose_ + return false; + } + ++ // the wrapped key (RFC 3394) is always the plaintext CEK length plus 8 bytes; ++ // enforce this before calling AES_unwrap_key, which would otherwise copy the ++ // attacker-controlled encrypted_key into the fixed-size jwe->cek buffer ++ if (recipient->enc_key.raw_len != jwe->cek_len + 8) ++ { ++ CJOSE_ERROR(err, CJOSE_ERR_INVALID_ARG); ++ return false; ++ } ++ + // AES unwrap the CEK in to jwe->cek + int len = AES_unwrap_key(&akey, (const unsigned char *)NULL, jwe->cek, (const unsigned char *)recipient->enc_key.raw, + recipient->enc_key.raw_len); diff --git a/debian/patches/CVE-2026-53939.patch b/debian/patches/CVE-2026-53939.patch new file mode 100644 index 0000000..b8a6d36 - --- /dev/null +++ b/debian/patches/CVE-2026-53939.patch @@ -0,0 +1,30 @@ +Description: fix all-zero content-encryption key for AES-CBC-HMAC + _cjose_jwe_set_cek_aes_cbc() passed the wrong boolean to + _cjose_jwe_malloc()'s "zero" parameter (inverted), so a freshly + generated CEK for AES-CBC-HMAC content encryption was always + zero-filled instead of randomly generated. Every JWE produced with + an AES-CBC-HMAC enc algorithm was therefore encrypted and + authenticated under the same fixed, publicly known key, letting + anyone who obtains such a JWE recover the plaintext and forge or + modify the content. +Origin: upstream, https://github.com/OpenIDC/cjose/commit/2a6e5bd96 +Bug: https://github.com/OpenIDC/cjose/security/advisories +Forwarded: not-needed +Applied-Upstream: 0.6.2.6 +Last-Update: 2026-09-13 +--- a/src/jwe.c ++++ b/src/jwe.c +@@ -455,11 +455,11 @@ static bool _cjose_jwe_set_cek_aes_cbc(cjose_jwe_t *jwe, const cjose_jwk_t *jwk, + if (strcmp(enc, CJOSE_HDR_ENC_A256CBC_HS512) == 0) + keysize = 64; + ++ // if no JWK is provided, generate a random key + if (NULL == jwk) + { +- // allocate memory for the CEK and fill with random bytes or 0's + _cjose_release_cek(&jwe->cek, jwe->cek_len); +- if (!_cjose_jwe_malloc(keysize, !random, &jwe->cek, err)) ++ if (!_cjose_jwe_malloc(keysize, random, &jwe->cek, err)) + { + return false; + } diff --git a/debian/patches/series b/debian/patches/series new file mode 100644 index 0000000..4b6c841 - --- /dev/null +++ b/debian/patches/series @@ -0,0 +1,2 @@ +CVE-2026-53938.patch +CVE-2026-53939.patch -----BEGIN PGP SIGNATURE----- iHUEARYKAB0WIQS82VxZRu76KBuHHJUjLGgvIGfW/wUCaqkQVAAKCRAjLGgvIGfW /9rEAP40notHHI/rOCLMCaZAxg47vFO4dRKMbYOSymRUMBE0cgEAkv5xXEIbIOfj DMN7LLNVUDbFUqlafCgT+FuBLV5MuAM= =X/VH -----END PGP SIGNATURE-----
-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 diff --git a/debian/changelog b/debian/changelog index 56c8bc5..f16db78 100644 - --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,19 @@ +git-lfs (3.3.0-1+deb12u2) bookworm-security; urgency=high + + * Fix CVE-2025-26625: git lfs checkout and git lfs pull wrote object + content via os.Create(), which follows symlinks and truncates + hardlink targets in place. A crafted repository containing a + symlink or hardlink colliding with a tracked path could make + either command write to an arbitrary location outside the working + tree. Fixed by removing any existing entry at the target path + first, then creating the replacement file exclusively. + * Further harden the same fix: check every intermediate directory + component of the write path for a symlink/hardlink standing in + for a real directory, not just the final component; and refuse to + run git lfs checkout/pull inside a bare repository, where the + write path has no defined relationship to a working tree at all. + + -- Ivo Marino <[email protected]> Mon, 14 Sep 2026 09:30:00 +0000 git-lfs (3.3.0-1+deb12u1) bookworm-security; urgency=high * Non-maintainer upload by the Security Team. diff --git a/debian/patches/0003-CVE-2025-26625-symlink-hardlink-write-outside-tree.patch b/debian/patches/0003-CVE-2025-26625-symlink-hardlink-write-outside-tree.patch new file mode 100644 index 0000000..61601a7 - --- /dev/null +++ b/debian/patches/0003-CVE-2025-26625-symlink-hardlink-write-outside-tree.patch @@ -0,0 +1,67 @@ +Description: reject symlink/hardlink writes outside the working tree on checkout/pull + git lfs checkout and git lfs pull wrote Git LFS object content via + os.Create(), which follows a symbolic link at the target path and + truncates-in-place any file with additional hard links, rather than + replacing it. A crafted repository containing a symlink or hardlink + colliding with a path Git LFS is about to populate can make either + command write arbitrary content to a location outside the working + tree entirely. Fix by removing any existing entry at the target path + first, then creating the replacement file exclusively (O_EXCL), so a + symlink or hardlink is rejected rather than followed or written + through. Adapted for this version's manifest parameter, which is + still a *tq.Manifest pointer here (changed to a plain tq.Manifest + value further upstream, unrelated to this fix). +Origin: upstream, https://github.com/git-lfs/git-lfs/commit/5c11ffce9a4f095ff356bc781e2a031abb46c1a8 +Bug: https://github.com/git-lfs/git-lfs/security/advisories/GHSA-6pvw-g552-53c5 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1118339 +Forwarded: not-needed +Applied-Upstream: 3.7.1 +Last-Update: 2026-09-13 +--- a/lfs/gitfilter_smudge.go ++++ b/lfs/gitfilter_smudge.go +@@ -18,15 +18,17 @@ import ( + func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download bool, manifest *tq.Manifest, cb tools.CopyCallback) error { + tools.MkdirAll(filepath.Dir(filename), f.cfg) + +- if stat, _ := os.Stat(filename); stat != nil && stat.Mode()&0200 == 0 { +- if err := os.Chmod(filename, stat.Mode()|0200); err != nil { +- return errors.Wrap(err, +- tr.Tr.Get("Could not restore write permission")) ++ // When no pointer file exists on disk, we should use the permissions ++ // defined for the file in Git, since the executable mode may be set. ++ // However, to conform with our legacy behaviour, we do not do this ++ // at present. (Backported from upstream fix for CVE-2025-26625.) ++ var mode os.FileMode = 0666 ++ if stat, _ := os.Lstat(filename); stat != nil && stat.Mode().IsRegular() { ++ if ptr.Size == 0 && stat.Size() == 0 { ++ return nil + } + +- // When we're done, return the file back to its normal +- // permission bits. +- defer os.Chmod(filename, stat.Mode()) ++ mode = stat.Mode().Perm() + } + + abs, err := filepath.Abs(filename) +@@ -34,9 +36,18 @@ func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download bool, m + return errors.New(tr.Tr.Get("could not produce absolute path for %q", filename)) + } + +- file, err := os.Create(abs) ++ // CVE-2025-26625: os.Create() follows symlinks and truncates ++ // hardlink targets, letting a crafted repo make LFS write outside ++ // the working tree. Remove any existing entry first, then create ++ // the file exclusively so a concurrently-recreated symlink/hardlink ++ // is rejected rather than followed. ++ if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { ++ return errors.Wrap(err, tr.Tr.Get("could not remove working directory file %q", filename)) ++ } ++ ++ file, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { +- return errors.New(tr.Tr.Get("could not create working directory file: %v", err)) ++ return errors.Wrap(err, tr.Tr.Get("could not create working directory file %q", filename)) + } + defer file.Close() + if _, err := f.Smudge(file, ptr, filename, download, manifest, cb); err != nil { diff --git a/debian/patches/0004-CVE-2025-26625-hardening.patch b/debian/patches/0004-CVE-2025-26625-hardening.patch new file mode 100644 index 0000000..5f8b162 - --- /dev/null +++ b/debian/patches/0004-CVE-2025-26625-hardening.patch @@ -0,0 +1,817 @@ +Description: harden checkout/pull further against CVE-2025-26625 (symlink/hardlink writes) + The previous patch in this series (CVE-2025-26625.patch) fixed the + core vulnerability: os.Create() at the final write target followed a + symlink and truncated a hardlink in place instead of replacing it. + This patch backports upstream's two follow-up hardening commits for + the same CVE: + . + 1. A symlink/hardlink standing in for one of the INTERMEDIATE + directory components in the write path (e.g. "dir1" in + "dir1/a.dat") was not checked at all -- only the final path + component was. Adds a DirWalker (tools/dir_walker.go) that walks + every path component from the working tree root, checking each is + a real directory (or creating it, on the checkout/pull code path + that needs to). Wired into both "git lfs checkout" (via a + tools.MkdirAll() call added at the --to flag's call site, since + SmudgeToFile() no longer creates directories itself) and "git lfs + pull" (via the new DirWalker directly). + 2. "git lfs checkout"/"git lfs pull" would still attempt to operate + against a bare repository, where cwd has no defined relationship + to a working tree at all. Both commands now check + cfg.LocalWorkingDir() up front and refuse cleanly instead. + . + Adapted for Debian bookworm's actual build toolchain (golang-1.19, + confirmed via `dpkg -l`): upstream's DirWalker uses the git-lfs + project's own errors.Join() (itself a thin alias for the Go 1.20+ + stdlib errors.Join) to attach a sentinel error for errors.Is() + checks. Go 1.19 predates both the stdlib function and errors.Is's + support for multi-error Unwrap() []error, so this patch uses a plain + fmt.Errorf("%s: %w", ...) single-error wrap instead -- supported + since Go 1.13, and fully sufficient here since each call site only + ever tests for one sentinel at a time. tools/dir_walker_test.go + itself is otherwise byte-for-byte the same as upstream's and passes + unmodified against this adaptation. + . + Also adapted for bookworm's own git-lfs 3.3.0 code shape (predating + both commits by several releases): "commands/pull.go"'s singleCheckout + already converts a Git-relative path to a cwd-relative one via its own + pathConverter (removed further upstream, after this Debian version, in + favor of always assuming cwd == working tree root) -- the DirWalker is + pointed at that already-converted cwd-relative path (cwdfilepath), + which is what actually gets written to disk, rather than upstream's + unconverted p.Name. + . + Verified: go build ./... (exit 0) and go test ./tools/... ./lfs/... + ./commands/... (all ok) on devports-debian, per RULE 1 -- go vet's two + pre-existing warnings (git-lfs.go's unbuffered signal channel, + lfshttp/client.go's mutex-by-value copy) are unrelated, pre-existing + in 3.3.0 itself, not introduced by this patch. +Origin: upstream, https://github.com/git-lfs/git-lfs/commit/0cffe93176b870055c9dadbb3cc9a4a440e98396, https://github.com/git-lfs/git-lfs/commit/d02bd13f02ef76f6807581cd6b34709069cb3615 +Bug: https://github.com/git-lfs/git-lfs/security/advisories/GHSA-6pvw-g552-53c5 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1118339 +Forwarded: not-needed +Applied-Upstream: 3.7.1 +Last-Update: 2026-09-14 +diff --git a/commands/command_checkout.go b/commands/command_checkout.go +index c46d5f6..4fe49c3 100644 +--- a/commands/command_checkout.go ++++ b/commands/command_checkout.go +@@ -3,12 +3,14 @@ package commands + import ( + "fmt" + "os" ++ "path/filepath" + + "github.com/git-lfs/git-lfs/v3/errors" + "github.com/git-lfs/git-lfs/v3/filepathfilter" + "github.com/git-lfs/git-lfs/v3/git" + "github.com/git-lfs/git-lfs/v3/lfs" + "github.com/git-lfs/git-lfs/v3/tasklog" ++ "github.com/git-lfs/git-lfs/v3/tools" + "github.com/git-lfs/git-lfs/v3/tq" + "github.com/git-lfs/git-lfs/v3/tr" + "github.com/spf13/cobra" +@@ -24,6 +26,14 @@ var ( + func checkoutCommand(cmd *cobra.Command, args []string) { + setupRepository() + ++ // CVE-2025-26625 hardening: a bare repository has no working tree ++ // to check out into; refuse rather than let path handling below ++ // operate against an undefined "current directory". ++ if cfg.LocalWorkingDir() == "" { ++ Print(tr.Tr.Get("This operation must be run in a work tree.")) ++ os.Exit(0) ++ } ++ + stage, err := whichCheckout() + if err != nil { + Exit(tr.Tr.Get("Error parsing args: %v", err)) +@@ -93,6 +103,14 @@ func checkoutCommand(cmd *cobra.Command, args []string) { + } + + func checkoutConflict(file string, stage git.IndexStage) { ++ // CVE-2025-26625: SmudgeToFile() no longer creates the destination ++ // directory itself (that responsibility moved to callers, alongside ++ // the new symlink-aware DirWalker used by the normal checkout/pull ++ // path below) -- the --to flag's target directory still needs it. ++ if err := tools.MkdirAll(filepath.Dir(checkoutTo), cfg); err != nil { ++ Exit(tr.Tr.Get("Could not create path %q: %v", checkoutTo, err)) ++ } ++ + singleCheckout := newSingleCheckout(cfg.Git, "") + if singleCheckout.Skip() { + fmt.Println(tr.Tr.Get("Cannot checkout LFS objects, Git LFS is not installed.")) +diff --git a/commands/pull.go b/commands/pull.go +index 644e520..9bc2ded 100644 +--- a/commands/pull.go ++++ b/commands/pull.go +@@ -11,6 +11,7 @@ import ( + "github.com/git-lfs/git-lfs/v3/git" + "github.com/git-lfs/git-lfs/v3/lfs" + "github.com/git-lfs/git-lfs/v3/subprocess" ++ "github.com/git-lfs/git-lfs/v3/tools" + "github.com/git-lfs/git-lfs/v3/tq" + "github.com/git-lfs/git-lfs/v3/tr" + ) +@@ -34,6 +35,7 @@ func newSingleCheckout(gitEnv config.Environment, remote string) abstractCheckou + + return &singleCheckout{ + gitIndexer: &gitIndexer{}, ++ hasWorkTree: cfg.LocalWorkingDir() != "", + pathConverter: pathConverter, + manifest: manifest, + } +@@ -49,6 +51,7 @@ type abstractCheckout interface { + + type singleCheckout struct { + gitIndexer *gitIndexer ++ hasWorkTree bool + pathConverter lfs.PathConverter + manifest *tq.Manifest + } +@@ -62,10 +65,34 @@ func (c *singleCheckout) Skip() bool { + } + + func (c *singleCheckout) Run(p *lfs.WrappedPointer) { ++ if !c.hasWorkTree { ++ return ++ } ++ + cwdfilepath := c.pathConverter.Convert(p.Name) + +- // Check the content - either missing or still this pointer (not exist is ok) +- filepointer, err := lfs.DecodePointerFromFile(cwdfilepath) ++ // CVE-2025-26625: check every path component from the working tree ++ // root to cwdfilepath for a symlink/hardlink standing in for a ++ // directory before writing through it (a bare os.Stat/Lstat on just ++ // the final component, as this used to do implicitly via ++ // DecodePointerFromFile, does not catch a symlinked intermediate ++ // directory). ++ dirWalker := tools.NewDirWalkerForFile("", cwdfilepath, cfg) ++ walkErr := dirWalker.Walk() ++ ++ var filepointer *lfs.Pointer ++ var err error ++ if walkErr != nil { ++ if !os.IsNotExist(walkErr) { ++ LoggedError(walkErr, tr.Tr.Get("Checkout error trying to check path for %q: %s", cwdfilepath, walkErr)) ++ return ++ } ++ err = walkErr ++ } else { ++ // Check the content - either missing or still this pointer (not exist is ok) ++ filepointer, err = lfs.DecodePointerFromFile(cwdfilepath) ++ } ++ + if err != nil && !os.IsNotExist(err) { + if errors.IsNotAPointerError(err) || errors.IsBadPointerKeyError(err) { + // File has non-pointer content, leave it alone +@@ -82,6 +109,13 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { + return + } + ++ if err != nil && os.IsNotExist(err) { ++ if err := dirWalker.WalkAndCreate(); err != nil { ++ LoggedError(err, tr.Tr.Get("Checkout error trying to create path for %q: %s", cwdfilepath, err)) ++ return ++ } ++ } ++ + if err := c.RunToPath(p, cwdfilepath); err != nil { + if errors.IsDownloadDeclinedError(err) { + // acceptable error, data not local (fetch not run or include/exclude) +diff --git a/lfs/gitfilter_smudge.go b/lfs/gitfilter_smudge.go +index 0ba031e..b815870 100644 +--- a/lfs/gitfilter_smudge.go ++++ b/lfs/gitfilter_smudge.go +@@ -16,8 +16,6 @@ import ( + ) + + func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download bool, manifest *tq.Manifest, cb tools.CopyCallback) error { +- tools.MkdirAll(filepath.Dir(filename), f.cfg) +- + // When no pointer file exists on disk, we should use the permissions + // defined for the file in Git, since the executable mode may be set. + // However, to conform with our legacy behaviour, we do not do this +diff --git a/tools/dir_walker.go b/tools/dir_walker.go +new file mode 100644 +index 0000000..5b23e4b +--- /dev/null ++++ b/tools/dir_walker.go +@@ -0,0 +1,117 @@ ++package tools ++ ++import ( ++ "fmt" ++ "os" ++ "strings" ++ ++ "github.com/git-lfs/git-lfs/v3/errors" ++ "github.com/git-lfs/git-lfs/v3/tr" ++) ++ ++// errInvalidDir and errNotDir are sentinel errors checked via errors.Is() ++// by callers and tests. Backported for a Go 1.19 toolchain (Debian ++// bookworm's actual golang-1.19 package): upstream wraps these with the ++// git-lfs errors package's own Join(), which is itself a thin alias for ++// the Go 1.20+ stdlib errors.Join. Go 1.19 predates both the stdlib ++// function and errors.Is's support for multi-error Unwrap() []error, so ++// instead we use a plain %w single-error wrap (fmt.Errorf), which ++// errors.Is has supported since Go 1.13 and is fully sufficient here: ++// each call site only ever needs to test for one sentinel at a time. ++var ( ++ errInvalidDir = errors.New(tr.Tr.Get("invalid directory")) ++ errNotDir = errors.New(tr.Tr.Get("not a directory")) ++) ++ ++type DirWalker struct { ++ parentPath string ++ path string ++ config repositoryPermissionFetcher ++} ++ ++// The parentPath parameter is assumed to be a valid path to a directory ++// in the filesystem. ++// ++// The filePath parameter must be a relative file path as provided by Git, ++// with only the "/" character as a separator and no empty or "." or ".." ++// path segments. Absolute paths are not supported. ++func NewDirWalkerForFile(parentPath string, filePath string, config repositoryPermissionFetcher) *DirWalker { ++ var path string ++ i := strings.LastIndexByte(filePath, '/') ++ if i >= 0 { ++ path = filePath[0:i] ++ } ++ ++ return &DirWalker{ ++ parentPath: parentPath, ++ path: path, ++ config: config, ++ } ++} ++ ++// walk() checks each directory in a relative path, starting from the ++// initial parent path, and optionally creates any missing directories ++// in the path. ++// ++// If an existing file or something else other than a directory conflicts ++// with a directory in the path, walk() returns an error. ++// ++// If the create option is false, walk() returns ErrNotExist when a ++// directory is not found. ++// ++// Note that for performance reasons and to be consistent with Git's ++// implementation, walk() does not guard against TOCTOU (time-of-check/ ++// time-of-use) races, as the methods of the os.Root type do. ++func (w *DirWalker) walk(create bool) error { ++ currentPath := w.parentPath ++ ++ n := len(w.path) ++ for n > 0 { ++ currentDir := w.path ++ nextDirIndex := n ++ i := strings.IndexByte(w.path, '/') ++ if i >= 0 { ++ currentDir = w.path[0:i] ++ nextDirIndex = i + 1 ++ } ++ ++ // These should never occur in Git paths. ++ if currentDir == "" || currentDir == "." || currentDir == ".." { ++ return fmt.Errorf("%s: %w", tr.Tr.Get("invalid directory %q in path: %q", currentDir, w.path), errInvalidDir) ++ } ++ ++ if currentPath == "" { ++ currentPath = currentDir ++ } else { ++ currentPath += "/" + currentDir ++ } ++ ++ stat, err := os.Lstat(currentPath) ++ if err != nil { ++ if !os.IsNotExist(err) || !create { ++ return err ++ } ++ ++ err = Mkdir(currentPath, w.config) ++ if err != nil { ++ return err ++ } ++ } else if !stat.Mode().IsDir() { ++ return fmt.Errorf("%s: %w", tr.Tr.Get("not a directory: %q", currentPath), errNotDir) ++ } ++ ++ w.parentPath = currentPath ++ w.path = w.path[nextDirIndex:] ++ n -= nextDirIndex ++ } ++ ++ return nil ++} ++ ++func (w *DirWalker) Walk() error { ++ return w.walk(false) ++} ++ ++func (w *DirWalker) WalkAndCreate() error { ++ return w.walk(true) ++} +diff --git a/tools/dir_walker_test.go b/tools/dir_walker_test.go +new file mode 100644 +index 0000000..33cd140 +--- /dev/null ++++ b/tools/dir_walker_test.go +@@ -0,0 +1,473 @@ ++package tools ++ ++import ( ++ "errors" ++ "fmt" ++ "os" ++ "testing" ++ ++ "github.com/stretchr/testify/assert" ++ "github.com/stretchr/testify/require" ++) ++ ++type newDirWalkerForFileTestCase struct { ++ filePath string ++ expectedDirPath string ++} ++ ++func (c *newDirWalkerForFileTestCase) Assert(t *testing.T) { ++ w := NewDirWalkerForFile("", c.filePath, nil) ++ assert.Equal(t, c.expectedDirPath, w.path) ++} ++ ++func TestNewDirWalkerForFile(t *testing.T) { ++ for desc, c := range map[string]*newDirWalkerForFileTestCase{ ++ "filename only": {"foo.bin", ""}, ++ "path with one dir": {"abc/foo.bin", "abc"}, ++ "path with two dirs": {"abc/def/foo.bin", "abc/def"}, ++ "path with leading slash": {"/foo.bin", ""}, ++ "path with trailing slash": {"abc/", "abc"}, ++ "bare slash": {"/", ""}, ++ "empty path": {"", ""}, ++ } { ++ t.Run(desc, c.Assert) ++ } ++} ++ ++type dirWalkerTestConfig struct{} ++ ++func (c *dirWalkerTestConfig) RepositoryPermissions(executable bool) os.FileMode { ++ return os.FileMode(0755) ++} ++ ++type dirWalkerWalkTestCase struct { ++ parentPath string ++ path string ++ create bool ++ ++ existsPath string ++ existsFile string ++ existsLink string ++ ++ expectedParentPath string ++ expectedPath string ++ expectedErr error ++ ++ walker *DirWalker ++} ++ ++func (c *dirWalkerWalkTestCase) prependParentPath(path string) string { ++ if path == "" { ++ return c.parentPath ++ } else if c.parentPath == "" { ++ return path ++ } else if path[0] == '/' { ++ return "/" + c.parentPath + path ++ } else { ++ return c.parentPath + "/" + path ++ } ++} ++ ++func (c *dirWalkerWalkTestCase) setupPaths(t *testing.T, parentPath string) error { ++ c.parentPath = parentPath ++ ++ if parentPath != "" { ++ if err := os.MkdirAll(parentPath, 0755); err != nil { ++ return fmt.Errorf("unable to create path: %w", err) ++ } ++ } ++ ++ if c.existsPath != "" { ++ c.existsPath = c.prependParentPath(c.existsPath) ++ if err := os.MkdirAll(c.existsPath, 0755); err != nil { ++ return fmt.Errorf("unable to create path: %w", err) ++ } ++ } ++ ++ if c.existsFile != "" { ++ c.existsFile = c.prependParentPath(c.existsFile) ++ f, err := os.Create(c.existsFile) ++ if err != nil { ++ return fmt.Errorf("unable to create file: %w", err) ++ } ++ f.Close() ++ } ++ ++ if c.existsLink != "" { ++ c.existsLink = c.prependParentPath(c.existsLink) ++ if err := os.Symlink(t.TempDir(), c.existsLink); err != nil { ++ return fmt.Errorf("unable to create symbolic link: %w", err) ++ } ++ } ++ ++ c.expectedParentPath = c.prependParentPath(c.expectedParentPath) ++ ++ return nil ++} ++ ++func (c *dirWalkerWalkTestCase) Assert(t *testing.T) { ++ c.walker.parentPath = c.parentPath ++ c.walker.path = c.path ++ ++ err := c.walker.walk(c.create) ++ ++ assert.Equal(t, c.expectedParentPath, c.walker.parentPath, "found path does not match") ++ assert.Equal(t, c.expectedPath, c.walker.path, "missing path does not match") ++ if c.expectedErr == nil { ++ assert.NoError(t, err) ++ } else { ++ assert.Error(t, err) ++ assert.True(t, errors.Is(err, c.expectedErr), "wrong error type") ++ } ++} ++ ++func TestDirWalkerWalk(t *testing.T) { ++ wd, err := os.Getwd() ++ require.NoError(t, err) ++ ++ defer os.Chdir(wd) ++ ++ for desc, c := range map[string]*dirWalkerWalkTestCase{ ++ "empty path": {}, ++ "one extant dir": { ++ path: "abc", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ }, ++ "one missing dir": { ++ path: "abc", ++ expectedPath: "abc", ++ expectedErr: os.ErrNotExist, ++ }, ++ "two extant dirs": { ++ path: "abc/def", ++ existsPath: "abc/def", ++ expectedParentPath: "abc/def", ++ }, ++ "two missing dirs": { ++ path: "abc/def", ++ expectedPath: "abc/def", ++ expectedErr: os.ErrNotExist, ++ }, ++ "three extant dirs": { ++ path: "abc/def/ghi", ++ existsPath: "abc/def/ghi", ++ expectedParentPath: "abc/def/ghi", ++ }, ++ "three missing dirs": { ++ path: "abc/def/ghi", ++ expectedPath: "abc/def/ghi", ++ expectedErr: os.ErrNotExist, ++ }, ++ "one extant dir and one missing dir": { ++ path: "abc/def", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ expectedPath: "def", ++ expectedErr: os.ErrNotExist, ++ }, ++ "one extant dir and two missing dirs": { ++ path: "abc/def/ghi", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ expectedPath: "def/ghi", ++ expectedErr: os.ErrNotExist, ++ }, ++ "two extant dirs and one missing dir": { ++ path: "abc/def/ghi", ++ existsPath: "abc/def", ++ expectedParentPath: "abc/def", ++ expectedPath: "ghi", ++ expectedErr: os.ErrNotExist, ++ }, ++ "one missing dir with trailing slash": { ++ path: "abc/", ++ expectedPath: "abc/", ++ expectedErr: os.ErrNotExist, ++ }, ++ "one extant dir with trailing slash": { ++ path: "abc/", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ }, ++ "two extant dirs with trailing slash": { ++ path: "abc/def/", ++ existsPath: "abc/def", ++ expectedParentPath: "abc/def", ++ }, ++ "one extant dir and one missing dir with trailing slash": { ++ path: "abc/def/", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ expectedPath: "def/", ++ expectedErr: os.ErrNotExist, ++ }, ++ "one conflicting file": { ++ path: "abc", ++ existsFile: "abc", ++ expectedPath: "abc", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir and one conflicting file": { ++ path: "abc/def", ++ existsPath: "abc", ++ existsFile: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def", ++ expectedErr: errNotDir, ++ }, ++ "two extant dirs and one conflicting file": { ++ path: "abc/def/ghi", ++ existsPath: "abc/def", ++ existsFile: "abc/def/ghi", ++ expectedParentPath: "abc/def", ++ expectedPath: "ghi", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir, one conflicting file, and one missing dir": { ++ path: "abc/def/ghi", ++ existsPath: "abc", ++ existsFile: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def/ghi", ++ expectedErr: errNotDir, ++ }, ++ "one conflicting symlink": { ++ path: "abc", ++ existsLink: "abc", ++ expectedPath: "abc", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir and one conflicting symlink": { ++ path: "abc/def", ++ existsPath: "abc", ++ existsLink: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def", ++ expectedErr: errNotDir, ++ }, ++ "two extant dirs and one conflicting symlink": { ++ path: "abc/def/ghi", ++ existsPath: "abc/def", ++ existsLink: "abc/def/ghi", ++ expectedParentPath: "abc/def", ++ expectedPath: "ghi", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir, one conflicting symlink, and one missing dir": { ++ path: "abc/def/ghi", ++ existsPath: "abc", ++ existsLink: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def/ghi", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir (not modified)": { ++ path: "abc", ++ create: true, ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ }, ++ "one created dir": { ++ path: "abc", ++ create: true, ++ expectedParentPath: "abc", ++ }, ++ "two extant dirs (not modified)": { ++ path: "abc/def", ++ create: true, ++ existsPath: "abc/def", ++ expectedParentPath: "abc/def", ++ }, ++ "two created dirs": { ++ path: "abc/def", ++ create: true, ++ expectedParentPath: "abc/def", ++ }, ++ "three extant dirs (not modified)": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc/def/ghi", ++ expectedParentPath: "abc/def/ghi", ++ }, ++ "three created dirs": { ++ path: "abc/def/ghi", ++ create: true, ++ expectedParentPath: "abc/def/ghi", ++ }, ++ "one extant dir and one created dir": { ++ path: "abc/def", ++ create: true, ++ existsPath: "abc", ++ expectedParentPath: "abc/def", ++ }, ++ "one extant dir and two created dirs": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc", ++ expectedParentPath: "abc/def/ghi", ++ }, ++ "two extant dirs and one created dir": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc/def", ++ expectedParentPath: "abc/def/ghi", ++ }, ++ "one created dir with trailing slash": { ++ path: "abc/", ++ create: true, ++ expectedParentPath: "abc", ++ }, ++ "one extant dir with trailing slash (not modified)": { ++ path: "abc/", ++ create: true, ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ }, ++ "two extant dirs with trailing slash (not modified)": { ++ path: "abc/def/", ++ create: true, ++ existsPath: "abc/def", ++ expectedParentPath: "abc/def", ++ }, ++ "one extant dir and one created dir with trailing slash": { ++ path: "abc/def/", ++ create: true, ++ existsPath: "abc", ++ expectedParentPath: "abc/def", ++ }, ++ "one conflicting file (not modified)": { ++ path: "abc", ++ create: true, ++ existsFile: "abc", ++ expectedPath: "abc", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir and one conflicting file (not modified)": { ++ path: "abc/def", ++ create: true, ++ existsPath: "abc", ++ existsFile: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def", ++ expectedErr: errNotDir, ++ }, ++ "two extant dirs and one conflicting file (not modified)": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc/def", ++ existsFile: "abc/def/ghi", ++ expectedParentPath: "abc/def", ++ expectedPath: "ghi", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir, one conflicting file, and one missing dir (not modified)": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc", ++ existsFile: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def/ghi", ++ expectedErr: errNotDir, ++ }, ++ "one conflicting symlink (not modified)": { ++ path: "abc", ++ create: true, ++ existsLink: "abc", ++ expectedPath: "abc", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir and one conflicting symlink (not modified)": { ++ path: "abc/def", ++ create: true, ++ existsPath: "abc", ++ existsLink: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def", ++ expectedErr: errNotDir, ++ }, ++ "two extant dirs and one conflicting symlink (not modified)": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc/def", ++ existsLink: "abc/def/ghi", ++ expectedParentPath: "abc/def", ++ expectedPath: "ghi", ++ expectedErr: errNotDir, ++ }, ++ "one extant dir, one conflicting symlink, and one missing dir (not modified)": { ++ path: "abc/def/ghi", ++ create: true, ++ existsPath: "abc", ++ existsLink: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "def/ghi", ++ expectedErr: errNotDir, ++ }, ++ "invalid bare slash": { ++ path: "/", ++ expectedPath: "/", ++ expectedErr: errInvalidDir, ++ }, ++ "invalid multiple slashes": { ++ path: "abc//def", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ expectedPath: "/def", ++ expectedErr: errInvalidDir, ++ }, ++ "invalid leading slash": { ++ path: "/abc", ++ existsPath: "abc", ++ expectedPath: "/abc", ++ expectedErr: errInvalidDir, ++ }, ++ "invalid bare dot component": { ++ path: ".", ++ expectedPath: ".", ++ expectedErr: errInvalidDir, ++ }, ++ "invalid dot component": { ++ path: "abc/./def", ++ existsPath: "abc/def", ++ expectedParentPath: "abc", ++ expectedPath: "./def", ++ expectedErr: errInvalidDir, ++ }, ++ "invalid bare double-dot component": { ++ path: "..", ++ expectedPath: "..", ++ expectedErr: errInvalidDir, ++ }, ++ "invalid double-dot component": { ++ path: "abc/../def", ++ existsPath: "abc", ++ expectedParentPath: "abc", ++ expectedPath: "../def", ++ expectedErr: errInvalidDir, ++ }, ++ } { ++ if err := os.Chdir(t.TempDir()); err != nil { ++ t.Errorf("unable to change directory: %s", err) ++ } ++ ++ c.walker = &DirWalker{ ++ config: &dirWalkerTestConfig{}, ++ } ++ ++ if err := c.setupPaths(t, ""); err != nil { ++ t.Error(err) ++ continue ++ } ++ ++ t.Run(desc, c.Assert) ++ ++ // retest with parent path; note that this alters the test case ++ if err := c.setupPaths(t, "foo/bar"); err != nil { ++ t.Error(err) ++ continue ++ } ++ ++ t.Run(desc+" with parent path", c.Assert) ++ } ++} +diff --git a/tools/filetools.go b/tools/filetools.go +index 7ec2e24..bfd5950 100644 +--- a/tools/filetools.go ++++ b/tools/filetools.go +@@ -122,6 +122,15 @@ type repositoryPermissionFetcher interface { + RepositoryPermissions(executable bool) os.FileMode + } + ++// Mkdir makes a directory with the ++// permissions specified by the core.sharedRepository setting. ++func Mkdir(path string, config repositoryPermissionFetcher) error { ++ umask := 0777 & ^config.RepositoryPermissions(true) ++ return doWithUmask(int(umask), func() error { ++ return os.Mkdir(path, config.RepositoryPermissions(true)) ++ }) ++} ++ + // MkdirAll makes a directory and any intervening directories with the + // permissions specified by the core.sharedRepository setting. + func MkdirAll(path string, config repositoryPermissionFetcher) error { diff --git a/debian/patches/series b/debian/patches/series index ab1b745..4285676 100644 - --- a/debian/patches/series +++ b/debian/patches/series @@ -1 +1,3 @@ 0002-CVE-2024-53263-reject-lf-in-creds.patch +0003-CVE-2025-26625-symlink-hardlink-write-outside-tree.patch +0004-CVE-2025-26625-hardening.patch -----BEGIN PGP SIGNATURE----- iHUEARYKAB0WIQS82VxZRu76KBuHHJUjLGgvIGfW/wUCaqkQVAAKCRAjLGgvIGfW /82nAQC6t4ePMbITL6eYbj12t9jOOtAsyX9POGUQcnY4KZ40CAD7BwJLmy/R32wN wdBNmxS1oP5Meg+UlmW0xZGkjpISeQk= =d96u -----END PGP SIGNATURE-----
