Skip to content

Commit 0840e54

Browse files
authored
fix(snapshot azure): reject zip entries that would extract outside the temp dir (#1175)
* fix(snapshot azure): reject zip entries that would extract outside the temp dir snapshot azure downloads each zip-deployed Web App's package and extracts it by joining every entry name onto a temp directory, with no check that the result stayed inside. An entry named "../../x" wrote outside the temp directory, as the account running the snapshot, on every platform. The S3 snapshot already guards this shape in localPathForS3Key. That rule now lives in utils.LocalRelativePath so both paths, and any future place an external name becomes a local path, apply the same containment check: a segment resolving to ".." (including ".. " and "..." as Windows reads them) is rejected, only a leading "/" is trimmed, and the remainder must satisfy filepath.IsLocal. The zip path fails the snapshot naming the offending entry rather than skipping it, so a tampered package is not silently fingerprinted without part of its content. * fix(snapshot azure): tolerate a "./" entry, pin symlink handling, export the rejection sentinels utils.LocalRelativePath now returns ErrPathTraversal, ErrNamesNoFile and ErrNotLocalPath so callers tell an attack apart from a harmless entry with errors.Is rather than by matching message text. The messages are unchanged. A "./" directory entry names the extraction directory itself and was a no-op before the containment rule; it is skipped again instead of failing the snapshot. A file entry that names no file still fails. A symlink entry has always landed as a regular file because os.OpenFile only honours permission bits; that is now explicit with Mode().Perm() and pinned by a test, since name containment cannot survive a real symlink that a later entry or the fingerprinter would follow out of the directory. The unzip wrapper now wraps with %w. The app name was already prefixed by the caller in GetAzureAppsData. * fix(snapshot s3): keep the rejection sentinel through the object-key error unusableS3KeyError now takes the reason as an error and wraps it with %w, so errors.Is(err, utils.ErrPathTraversal) holds on the S3 path as it already did through utils.ContainedPath. Message text is unchanged. Also corrects the comment on the zip extraction open call: os.OpenFile never creates a symlink whatever the type bits, so the choice of OpenFile over os.Symlink is what keeps a symlink entry a regular file. What Perm() adds is dropping setuid, setgid and sticky, which OpenFile does honour. * fix(snapshot azure): name the entry on every extraction error and write entries with a constant mode The rejection message now says the app cannot be reported until it is redeployed without the entry: snapshot azure has no exclude flag and .kosli_ignore is only read after extraction, so a changed package is the only remedy. "..." and ".. " are legal names off Windows but the shared rule still rejects them, so the message matters there too. Every other extraction failure is wrapped with the entry name, and a file "a" alongside an entry "a/b" gets the same actionable sentence the S3 path gives an object and a prefix, instead of a bare ENOTDIR on a temp path. Entries are written 0o600 rather than with the archive's permission bits. The tree is deleted once fingerprinted and the fingerprint never reads a mode, so this is fingerprint-identical; it keeps a zero-mode entry readable, which previously failed later inside the fingerprinter, and still drops setuid, setgid and sticky. The two key-caused S3 rejections are package-level sentinels, matching the utils style. * fix(snapshot azure): say a rejected entry fails the whole snapshot, and name either side of a name collision One app's error cancels the run in GetAzureAppsData, so a rejected entry means no app in the environment is reported, not only the offending one. The help text and the runtime error now say so. A directory entry over an earlier file was reported as a parent problem, and a file entry over an earlier directory fell through to a bare EISDIR carrying the temp path. Both are checked up front with Lstat so the error names the colliding entry, says which kind was there first, and fails the same way on every platform. The parent-is-a-file case keeps its message. * fix(snapshot azure): fail on two entries landing on one path, and on a write error surfacing at Close O_EXCL replaces O_TRUNC so the second of two entries resolving to the same path fails the snapshot, naming the entry, instead of silently replacing the first. Duplicates are legal in a zip, and containment collapses "x", "/x" and "./x" onto one path, so this was more reachable than before. It mirrors errPathCollision on the S3 path: a package is never fingerprinted without part of its content. A destination Close error is now returned when Copy succeeded, since a write error can surface only there and the entry would otherwise be fingerprinted truncated. Directories are created 0o700, consistent with the constant file mode. * fix(snapshot azure): name the case-insensitive filesystem remedy on a path collision Two entries differing only in case collide on macOS and Windows but not on Linux, so the same package can snapshot from one machine and fail from another. The operator has no flag to exclude an entry, so the error now says to run the snapshot on a case-sensitive filesystem. The escape-test table no longer indexes the last byte of an entry name, which panicked on an empty name; that row is now in the table. * fix(snapshot azure): name both entries of a path collision, and give the case-insensitivity advice only when it applies unzip now records each extracted path with the entry that produced it, so two entries resolving to one path fail with both names: "zip entry [dir/./x]: resolves to the same local path as entry [dir/x]". That case fails identically on every platform and the operator can see what to fix. Only when no recorded entry resolves to the path, yet the file exists, is the filesystem equating two names the package keeps distinct, as macOS and Windows do for case. That branch alone advises running the snapshot on a case-sensitive filesystem. The escape test asserts the bracketed entry name, so the empty-name row checks that the message still names the entry rather than passing vacuously.
1 parent 80d835a commit 0840e54

7 files changed

Lines changed: 531 additions & 56 deletions

File tree

cmd/kosli/snapshotAzureApps.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ will not match. See
2424
https://learn.microsoft.com/en-us/azure/azure-functions/functions-app-settings#website_run_from_package
2525
2626
For zip-deployed apps, the fingerprint respects a ^.kosli_ignore^ file at the root of the deployed package.
27+
The package is extracted into a temporary directory. An entry whose name would resolve outside that directory
28+
(for example one containing a ^..^ segment) is never written; instead the whole snapshot fails and no app in
29+
the environment is reported until the offending app is redeployed without that entry.
2730
2831
With ^--digests-source acr^, the registry is taken from each app's own container configuration. Azure
2932
credentials are only ever sent to an Azure Container Registry login server. An app whose image comes

internal/aws/aws.go

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/kosli-dev/cli/internal/digest"
2929
"github.com/kosli-dev/cli/internal/filters"
3030
"github.com/kosli-dev/cli/internal/logger"
31+
"github.com/kosli-dev/cli/internal/utils"
3132
)
3233

3334
// EcsEnvRequest represents the PUT request body to be sent to kosli from ECS
@@ -556,36 +557,28 @@ func getS3DataFromClient(client S3API, bucket string, includePaths, includeRegex
556557
}
557558

558559
// localPathForS3Key turns an S3 object key into a path under the download
559-
// directory, or rejects it. A key holding a ".." segment resolves onto a path
560-
// it does not name, taking another key's place or leaving the directory.
560+
// directory, or rejects it. The containment rule is shared with every other
561+
// place an external name becomes a local path.
561562
func localPathForS3Key(key string) (string, error) {
562-
// Windows separates on '\\' and drops trailing dots and spaces from a
563-
// name, so ".. " and "..." resolve as ".." there.
564-
segments := strings.FieldsFunc(key, func(r rune) bool { return r == '/' || r == '\\' })
565-
for _, segment := range segments {
566-
if strings.HasPrefix(segment, "..") && strings.TrimRight(segment, ". ") == "" {
567-
return "", unusableS3KeyError(key, `contains a segment that resolves to ".."`)
568-
}
569-
}
570-
571-
// A leading '\\' is left for filepath.IsLocal: rooted on Windows, an
572-
// ordinary filename elsewhere.
573-
rel := strings.TrimLeft(key, "/")
574-
if filepath.Clean(rel) == "." {
575-
return "", unusableS3KeyError(key, "names no file")
576-
}
577-
if !filepath.IsLocal(rel) {
578-
return "", unusableS3KeyError(key, "is not a local path")
563+
rel, err := utils.LocalRelativePath(key)
564+
if err != nil {
565+
return "", unusableS3KeyError(key, err)
579566
}
580-
581567
return rel, nil
582568
}
583569

570+
// The key-caused rejections downloadFileFromBucket adds to those of
571+
// utils.LocalRelativePath.
572+
var (
573+
errParentPrefixIsObject = errors.New("one of its parent prefixes has already been downloaded as an object")
574+
errPathCollision = errors.New("another object already downloaded to the same local path")
575+
)
576+
584577
// unusableS3KeyError is only for failures the key itself causes. Advising
585578
// exclusion on a machine fault such as a full disk would drop a legitimate
586579
// object from the snapshot.
587-
func unusableS3KeyError(key, reason string) error {
588-
return fmt.Errorf("object key [%s] cannot be stored as a local file: %s; exclude it with --exclude-regex, or narrow the include filter if one is set", key, reason)
580+
func unusableS3KeyError(key string, reason error) error {
581+
return fmt.Errorf("object key [%s] cannot be stored as a local file: %w; exclude it with --exclude-regex, or narrow the include filter if one is set", key, reason)
589582
}
590583

591584
func downloadFileFromBucket(downloader S3DownloadAPI, dirName, key, bucket string, logger *logger.Logger) error {
@@ -597,7 +590,7 @@ func downloadFileFromBucket(downloader S3DownloadAPI, dirName, key, bucket strin
597590
err = os.MkdirAll(filepath.Dir(dest), 0770)
598591
if errors.Is(err, syscall.ENOTDIR) {
599592
// Legal in S3, impossible on disk: an object "a" and a key under "a/".
600-
return unusableS3KeyError(key, "one of its parent prefixes has already been downloaded as an object")
593+
return unusableS3KeyError(key, errParentPrefixIsObject)
601594
}
602595
if err != nil {
603596
return fmt.Errorf("object key [%s]: %w", key, err)
@@ -607,7 +600,7 @@ func downloadFileFromBucket(downloader S3DownloadAPI, dirName, key, bucket strin
607600
// "A/x" and "a/y" share one on a case-insensitive filesystem.
608601
file, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666)
609602
if errors.Is(err, fs.ErrExist) {
610-
return unusableS3KeyError(key, "another object already downloaded to the same local path")
603+
return unusableS3KeyError(key, errPathCollision)
611604
}
612605
if err != nil {
613606
return fmt.Errorf("object key [%s]: %w", key, err)

internal/aws/aws_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/kosli-dev/cli/internal/filters"
1616
"github.com/kosli-dev/cli/internal/logger"
1717
"github.com/kosli-dev/cli/internal/testHelpers"
18+
"github.com/kosli-dev/cli/internal/utils"
1819
"github.com/stretchr/testify/require"
1920
"github.com/stretchr/testify/suite"
2021
)
@@ -1320,6 +1321,12 @@ func (suite *AWSTestSuite) TestLocalPathForS3Key() {
13201321
}
13211322
}
13221323

1324+
func (suite *AWSTestSuite) TestLocalPathForS3KeyKeepsTheRejectionSentinel() {
1325+
_, err := localPathForS3Key("uploads/../protected/release.bin")
1326+
require.ErrorIs(suite.T(), err, utils.ErrPathTraversal)
1327+
require.Contains(suite.T(), err.Error(), "object key [uploads/../protected/release.bin]")
1328+
}
1329+
13231330
func (suite *AWSTestSuite) TestDownloadFileFromBucketRefusesToOverwrite() {
13241331
tempDir := suite.T().TempDir()
13251332
preexisting := filepath.Join(tempDir, "README.md")

internal/azure/azure_apps.go

Lines changed: 93 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"path/filepath"
1717
"strings"
1818
"sync"
19+
"syscall"
1920

2021
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
2122
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
@@ -26,6 +27,7 @@ import (
2627
"github.com/kosli-dev/cli/internal/digest"
2728
"github.com/kosli-dev/cli/internal/logger"
2829
"github.com/kosli-dev/cli/internal/server"
30+
"github.com/kosli-dev/cli/internal/utils"
2931
)
3032

3133
type AzureStaticCredentials struct {
@@ -274,7 +276,7 @@ func (azureClient *AzureClient) fingerprintZipService(app *armappservice.Site, l
274276
destDir := filepath.Join(tmpDir, "extracted")
275277
err = unzip(packagePath, destDir, logger)
276278
if err != nil {
277-
return AppData{}, fmt.Errorf("failed to unzip the downloaded package: %v", err)
279+
return AppData{}, fmt.Errorf("failed to unzip the downloaded package: %w", err)
278280
}
279281

280282
// fingerprint the downloaded and unzipped package
@@ -329,53 +331,112 @@ func unzip(zipFile, destDir string, logger *logger.Logger) error {
329331
}
330332
}()
331333

332-
for _, f := range r.File {
333-
filePath := filepath.Join(destDir, f.Name)
334+
// Resolved path to the entry name that produced it, so a collision can name
335+
// both sides.
336+
extracted := make(map[string]string, len(r.File))
334337

335-
if f.FileInfo().IsDir() {
336-
// Create directories
337-
err := os.MkdirAll(filePath, os.ModePerm)
338-
if err != nil {
339-
return err
340-
}
338+
for _, f := range r.File {
339+
// The entry name comes from the deployed package, which anyone able to
340+
// deploy the app controls, so it must not be able to leave destDir.
341+
filePath, err := utils.ContainedPath(destDir, f.Name)
342+
if errors.Is(err, utils.ErrNamesNoFile) && f.FileInfo().IsDir() {
343+
// A "./" entry names destDir itself; there is nothing to create.
341344
continue
342345
}
343-
344-
// Ensure the directory for the file exists
345-
if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
346-
return err
346+
if err != nil {
347+
// One app's error cancels the whole run, snapshot azure has no exclude
348+
// flag, and .kosli_ignore is only read after extraction, so the only
349+
// remedy is a changed package.
350+
return fmt.Errorf("zip entry %w; the package cannot be extracted safely, so no app in the environment is reported until this app is redeployed without that entry", err)
347351
}
348352

349-
// Open the destination file
350-
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
351-
if err != nil {
352-
return err
353+
if err := extractZipEntry(f, filePath, extracted, logger); err != nil {
354+
return fmt.Errorf("zip entry [%s]: %w", f.Name, err)
353355
}
356+
}
357+
return nil
358+
}
354359

355-
// Open the source file within the ZIP archive
356-
zipFile, err := f.Open()
357-
if err != nil {
358-
return err
360+
// extractZipEntry writes one entry to filePath, which the caller has already
361+
// checked stays inside the destination directory, and records it in extracted.
362+
func extractZipEntry(f *zip.File, filePath string, extracted map[string]string, logger *logger.Logger) error {
363+
isDir := f.FileInfo().IsDir()
364+
365+
// Legal in a zip, impossible on disk: one name as both a file and a
366+
// directory. Checked up front so the message names this entry rather than
367+
// whichever filesystem call happens to fail, and fails the same way on
368+
// every platform.
369+
if existing, statErr := os.Lstat(filePath); statErr == nil && existing.IsDir() != isDir {
370+
if existing.IsDir() {
371+
return errors.New("was already extracted as a directory")
359372
}
373+
return errors.New("was already extracted as a file")
374+
}
360375

361-
// Copy the file contents
362-
_, err = io.Copy(destFile, zipFile)
376+
dir := filePath
377+
if !isDir {
378+
dir = filepath.Dir(filePath)
379+
}
380+
err := os.MkdirAll(dir, 0o700)
381+
if errors.Is(err, syscall.ENOTDIR) {
382+
// A file "a" and an entry under "a/".
383+
return errors.New("one of its parent directories was already extracted as a file")
384+
}
385+
if err != nil {
386+
return err
387+
}
388+
if isDir {
389+
return nil
390+
}
391+
392+
// The tree is deleted once fingerprinted and the fingerprint never reads a
393+
// mode, so a constant is safe: it keeps a zero-mode entry readable and
394+
// drops setuid, setgid and sticky, which os.OpenFile would honour.
395+
// Writing every entry through OpenFile, never os.Symlink, is what keeps a
396+
// symlink entry from becoming a real symlink. Name containment does not
397+
// survive one, since a later entry or the fingerprinter would follow it
398+
// out of the destination.
399+
// Legal in a zip, and containment collapses "x", "/x" and "./x" onto one
400+
// path, but overwriting would fingerprint the package without the first
401+
// entry. This fails the same way on every platform.
402+
if first, dup := extracted[filePath]; dup {
403+
return fmt.Errorf("resolves to the same local path as entry [%s]", first)
404+
}
405+
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
406+
if errors.Is(err, os.ErrExist) {
407+
// No recorded entry resolves here, so the filesystem itself equates two
408+
// names: case, or Unicode form, on macOS and Windows. Moving the
409+
// snapshot is the operator's only remedy.
410+
return errors.New("collides with an earlier entry whose name this filesystem treats as the same, such as one differing only in case; run the snapshot on a case-sensitive filesystem")
411+
}
412+
if err != nil {
413+
return err
414+
}
415+
extracted[filePath] = f.Name
363416

364-
// Close the open files
417+
zipFile, err := f.Open()
418+
if err != nil {
365419
if closeErr := destFile.Close(); closeErr != nil {
366-
// Log warning for cleanup error
367420
logger.Warn("failed to close destination file %s: %v", filePath, closeErr)
368421
}
369-
if closeErr := zipFile.Close(); closeErr != nil {
370-
// Log warning for cleanup error
371-
logger.Warn("failed to close zip file: %v", closeErr)
372-
}
422+
return err
423+
}
373424

374-
if err != nil {
375-
return err
425+
_, err = io.Copy(destFile, zipFile)
426+
427+
// A write error can surface only at Close, and an entry truncated that
428+
// way would be fingerprinted as if it were complete.
429+
if closeErr := destFile.Close(); closeErr != nil {
430+
logger.Warn("failed to close destination file %s: %v", filePath, closeErr)
431+
if err == nil {
432+
err = closeErr
376433
}
377434
}
378-
return nil
435+
if closeErr := zipFile.Close(); closeErr != nil {
436+
logger.Warn("failed to close zip file: %v", closeErr)
437+
}
438+
439+
return err
379440
}
380441

381442
func (azureClient *AzureClient) fingerprintDockerService(app *armappservice.Site, logger *logger.Logger, imageName string) (AppData, error) {

0 commit comments

Comments
 (0)