mirror of
https://github.com/pgsty/minio.git
synced 2026-08-09 07:43:29 +03:00
fix(storage): validate internode paths and erasure payloads
Storage REST request bodies and Grid RPC frames bypass the HTTP validity middleware, allowing wire-supplied paths and malformed FileInfo values to reach xlStorage unchecked. Wrap the remotely exposed StorageAPI with a guard that covers every path-bearing method, including nested metadata fields. Reject traversal and destructive volume-root aliases before path cleaning can erase them, and validate erasure geometry and part sizes at the same wire boundary. Keep a raw-volume check in getVolDir for peer-S3 calls that bypass the wrapper. Reflection, fuzz, traversal, peer-S3, compatibility, and malformed-erasure tests pin the complete method surface and prove that legal object names remain accepted. Co-authored-by: ChatGPT <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,26 @@ func (e ErasureInfo) ShardSize() int64 {
|
||||
return ceilFrac(e.BlockSize, int64(e.DataBlocks))
|
||||
}
|
||||
|
||||
// HasNegativePartSize reports whether any part claims a negative size.
|
||||
//
|
||||
// Such metadata is never legitimate, and it is not merely cosmetic: a negative
|
||||
// length floors both terms of ShardFileSize to zero, and checkPart's only
|
||||
// integrity test is "st.Size() < expectedSize". A zero expectation is therefore
|
||||
// satisfied by every file that exists, including a truncated shard, so the part
|
||||
// is reported intact and a heal driven by the result skips the repair it should
|
||||
// have performed.
|
||||
//
|
||||
// Note this holds even when the erasure parameters are entirely valid, so
|
||||
// FileInfo.IsValid() - the check healing itself trusts - does not catch it.
|
||||
func (fi FileInfo) HasNegativePartSize() bool {
|
||||
for _, p := range fi.Parts {
|
||||
if p.Size < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValid - tells if erasure info fields are valid.
|
||||
func (fi FileInfo) IsValid() bool {
|
||||
if fi.Deleted {
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"runtime"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
xioutil "github.com/minio/minio/internal/ioutil"
|
||||
)
|
||||
|
||||
// guardedStorage rejects filesystem paths that arrive from an internode
|
||||
// payload and would resolve outside the volume they name.
|
||||
//
|
||||
// Why here and not in each handler: the global HTTP middleware validates only
|
||||
// r.URL.Path and r.Form (query arguments), and r.Form is never populated from
|
||||
// a request body. Paths carried in a msgpack body, or in a grid RPC frame on
|
||||
// the long-lived /minio/grid/v1 websocket, therefore reach xlStorage
|
||||
// unvalidated. Every storage-REST and grid handler obtains its StorageAPI from
|
||||
// storageRESTServer.getStorage(), so wrapping that one call covers all of them
|
||||
// at once, sees the nested struct fields a per-handler check tends to miss,
|
||||
// and cannot drift out of sync as handlers are added.
|
||||
//
|
||||
// Scope, deliberately narrow:
|
||||
//
|
||||
// - Only *path* arguments are checked here. Volume arguments are validated
|
||||
// at the sink by xlStorage.getVolDir, which additionally covers callers
|
||||
// that bypass this wrapper entirely (the peer-S3 bucket RPCs). Do not
|
||||
// conclude from the absence of a volume check here that volumes are inert.
|
||||
//
|
||||
// - Local callers - the erasure layer talking to its own drives - are not
|
||||
// wrapped. Their object names already passed IsValidObjectName at the S3
|
||||
// boundary, so wrapping them would add cost and regression risk without
|
||||
// adding a control.
|
||||
type guardedStorage struct {
|
||||
StorageAPI
|
||||
}
|
||||
|
||||
// isVolumeRootAlias reports whether p addresses the volume directory itself
|
||||
// rather than something inside it, i.e. whether pathJoin(volumeDir, p)
|
||||
// collapses back to volumeDir. That happens only for the empty string and for
|
||||
// strings made up entirely of separators.
|
||||
//
|
||||
// Whitespace must NOT be treated as a separator here, even though
|
||||
// hasBadPathComponent trims it when comparing a segment against "."/"..".
|
||||
// path.Clean does not touch spaces, so pathJoin(volumeDir, " ") is
|
||||
// "volumeDir/ " - a real directory named two spaces, not the volume root. A
|
||||
// whitespace-only object key is legal in S3 (IsValidObjectName accepts it) and
|
||||
// is committed through RenameData on the PutObject path, so rejecting it here
|
||||
// would fail those writes on every remote drive at once. See
|
||||
// TestGuardAcceptsEveryLegalObjectName.
|
||||
//
|
||||
// Three characters collapse a component on Windows but not on Unix, so the
|
||||
// rule is platform-split and exercised from either platform through
|
||||
// isVolumeRootAliasOn:
|
||||
//
|
||||
// - Backslash is a separator on Windows only. path.Clean never treats it as
|
||||
// one, so on Unix "\\" names an ordinary file and is a legal S3 object key;
|
||||
// refusing it there would make a distributed cluster reject a write that a
|
||||
// single-node server accepts.
|
||||
//
|
||||
// - Space and period are stripped from the end of a path component by the
|
||||
// Win32 normalisation layer. A component made only of those characters
|
||||
// therefore disappears and the path resolves to its parent - the volume
|
||||
// root. Go does not add the \\?\ prefix that would suppress this for the
|
||||
// short paths used here, so " " and "..." reach the syscall as an empty
|
||||
// component. On Unix they are ordinary filenames and legal object keys.
|
||||
//
|
||||
// Note the second point is reasoned from documented Win32 behaviour, not from a
|
||||
// Windows test run: CI is Linux-only, so TestIsVolumeRootAliasIsPlatformCorrect
|
||||
// pins both branches of the predicate rather than the syscall behaviour itself.
|
||||
func isVolumeRootAlias(p string) bool {
|
||||
return isVolumeRootAliasOn(p, runtime.GOOS == globalWindowsOSName)
|
||||
}
|
||||
|
||||
func isVolumeRootAliasOn(p string, windows bool) bool {
|
||||
for i := range len(p) {
|
||||
if p[i] == SlashSeparatorChar {
|
||||
continue
|
||||
}
|
||||
if windows && (p[i] == '\\' || p[i] == ' ' || p[i] == '.') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// guardErasureParams rejects a FileInfo from which no meaningful expected shard
|
||||
// size can be derived, because "no meaningful size" degrades to "everything
|
||||
// passes" rather than to an error.
|
||||
//
|
||||
// checkPart's only integrity test is "st.Size() < expectedSize". Whenever
|
||||
// ShardFileSize yields 0, that comparison is false for every file that exists -
|
||||
// including a truncated shard - so the part comes back reported healthy and a
|
||||
// heal driven by the result skips a shard that actually needs repair. Two
|
||||
// distinct inputs produce that 0:
|
||||
//
|
||||
// - Unusable erasure parameters with a part of positive size. ShardFileSize
|
||||
// returns 0 early so the division cannot panic.
|
||||
//
|
||||
// - A part of NEGATIVE size, with or without usable parameters: numShards
|
||||
// floors to 0 and ceilFrac of a negative numerator is 0, so the arithmetic
|
||||
// lands on 0 by itself. This one is easy to miss precisely because valid
|
||||
// erasure parameters do not save you from it.
|
||||
//
|
||||
// Parts of zero length are left alone - ShardFileSize legitimately returns 0
|
||||
// for them, so they say nothing about whether the metadata is sound.
|
||||
//
|
||||
// This is the boundary check; ShardFileSize's own zero-value guard stays as the
|
||||
// last line of defence against a panic.
|
||||
func guardErasureParams(fi FileInfo) error {
|
||||
// Negative sizes share their rule with the storage layer, which also has to
|
||||
// cope with metadata already on disk; keep the two from drifting apart by
|
||||
// asking the same predicate.
|
||||
if fi.HasNegativePartSize() {
|
||||
return errFileCorrupt
|
||||
}
|
||||
usable := fi.Erasure.BlockSize > 0 && fi.Erasure.DataBlocks > 0
|
||||
for _, p := range fi.Parts {
|
||||
if p.Size > 0 && !usable {
|
||||
return errFileCorrupt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardPaths rejects traversal in any of the supplied paths. The empty string
|
||||
// is allowed: it legitimately names the volume root for listing operations,
|
||||
// and an empty FileInfo.DataDir is normal for inline and transitioned objects.
|
||||
//
|
||||
// Callers pass the entire batch here before invoking storage, so a payload
|
||||
// mixing a valid target with a malicious one is rejected whole and performs no
|
||||
// partial work. (This returns on the first offending path; what matters is that
|
||||
// it runs to a verdict before any element has been acted on.)
|
||||
func guardPaths(paths ...string) error {
|
||||
for _, p := range paths {
|
||||
if hasBadPathComponent(p) {
|
||||
return errFileAccessDenied
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardObjectPaths is guardPaths plus a rejection of volume-root aliases. It
|
||||
// applies to the destructive verbs only - rename and bulk delete - because
|
||||
// those relocate or destroy the volume root when handed one, whereas reads and
|
||||
// writes of the volume root merely fail on their own.
|
||||
func guardObjectPaths(paths ...string) error {
|
||||
for _, p := range paths {
|
||||
if hasBadPathComponent(p) || isVolumeRootAlias(p) {
|
||||
return errFileAccessDenied
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardVersions validates every path-bearing field reachable through a
|
||||
// DeleteVersions payload: the per-object name, and the DataDir of each version.
|
||||
// DataDir is joined under the object directory, and may legitimately be empty
|
||||
// for inline and transitioned objects, so it gets guardPaths - never
|
||||
// guardObjectPaths.
|
||||
func guardVersions(versions []FileInfoVersions, opts DeleteOptions) error {
|
||||
if err := guardPaths(opts.OldDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range versions {
|
||||
if err := guardPaths(v.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fi := range v.Versions {
|
||||
if err := guardPaths(fi.DataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (g guardedStorage) DeleteVersion(ctx context.Context, volume, path string, fi FileInfo, forceDelMarker bool, opts DeleteOptions) error {
|
||||
if err := guardPaths(path, fi.DataDir, opts.OldDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.DeleteVersion(ctx, volume, path, fi, forceDelMarker, opts)
|
||||
}
|
||||
|
||||
func (g guardedStorage) DeleteVersions(ctx context.Context, volume string, versions []FileInfoVersions, opts DeleteOptions) []error {
|
||||
if err := guardVersions(versions, opts); err != nil {
|
||||
// The whole batch is refused: validating every element before acting on
|
||||
// any of it is what stops a payload mixing a valid target with a
|
||||
// malicious one from performing a partial delete.
|
||||
errs := make([]error, len(versions))
|
||||
for i := range errs {
|
||||
errs[i] = err
|
||||
}
|
||||
return errs
|
||||
}
|
||||
return g.StorageAPI.DeleteVersions(ctx, volume, versions, opts)
|
||||
}
|
||||
|
||||
func (g guardedStorage) DeleteBulk(ctx context.Context, volume string, paths ...string) error {
|
||||
if err := guardObjectPaths(paths...); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.DeleteBulk(ctx, volume, paths...)
|
||||
}
|
||||
|
||||
func (g guardedStorage) WriteMetadata(ctx context.Context, origvolume, volume, path string, fi FileInfo) error {
|
||||
if err := guardPaths(path, fi.DataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.WriteMetadata(ctx, origvolume, volume, path, fi)
|
||||
}
|
||||
|
||||
func (g guardedStorage) UpdateMetadata(ctx context.Context, volume, path string, fi FileInfo, opts UpdateMetadataOpts) error {
|
||||
if err := guardPaths(path, fi.DataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.UpdateMetadata(ctx, volume, path, fi, opts)
|
||||
}
|
||||
|
||||
func (g guardedStorage) ReadVersion(ctx context.Context, origvolume, volume, path, versionID string, opts ReadOptions) (FileInfo, error) {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
return g.StorageAPI.ReadVersion(ctx, origvolume, volume, path, versionID, opts)
|
||||
}
|
||||
|
||||
func (g guardedStorage) ReadXL(ctx context.Context, volume, path string, readData bool) (RawFileInfo, error) {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return RawFileInfo{}, err
|
||||
}
|
||||
return g.StorageAPI.ReadXL(ctx, volume, path, readData)
|
||||
}
|
||||
|
||||
func (g guardedStorage) RenameData(ctx context.Context, srcVolume, srcPath string, fi FileInfo, dstVolume, dstPath string, opts RenameOptions) (RenameDataResp, error) {
|
||||
if err := guardObjectPaths(srcPath, dstPath); err != nil {
|
||||
return RenameDataResp{}, err
|
||||
}
|
||||
// DataDir is empty for inline and transitioned objects, so it must not be
|
||||
// held to the non-empty rule above.
|
||||
if err := guardPaths(fi.DataDir); err != nil {
|
||||
return RenameDataResp{}, err
|
||||
}
|
||||
return g.StorageAPI.RenameData(ctx, srcVolume, srcPath, fi, dstVolume, dstPath, opts)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (g guardedStorage) ListDir(ctx context.Context, origvolume, volume, dirPath string, count int) ([]string, error) {
|
||||
if err := guardPaths(dirPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.ListDir(ctx, origvolume, volume, dirPath, count)
|
||||
}
|
||||
|
||||
func (g guardedStorage) ReadFile(ctx context.Context, volume, path string, offset int64, buf []byte, verifier *BitrotVerifier) (int64, error) {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return g.StorageAPI.ReadFile(ctx, volume, path, offset, buf, verifier)
|
||||
}
|
||||
|
||||
func (g guardedStorage) AppendFile(ctx context.Context, volume, path string, buf []byte) error {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.AppendFile(ctx, volume, path, buf)
|
||||
}
|
||||
|
||||
func (g guardedStorage) CreateFile(ctx context.Context, origvolume, volume, path string, size int64, reader io.Reader) error {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.CreateFile(ctx, origvolume, volume, path, size, reader)
|
||||
}
|
||||
|
||||
func (g guardedStorage) ReadFileStream(ctx context.Context, volume, path string, offset, length int64) (io.ReadCloser, error) {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.ReadFileStream(ctx, volume, path, offset, length)
|
||||
}
|
||||
|
||||
func (g guardedStorage) RenameFile(ctx context.Context, srcVolume, srcPath, dstVolume, dstPath string) error {
|
||||
if err := guardObjectPaths(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.RenameFile(ctx, srcVolume, srcPath, dstVolume, dstPath)
|
||||
}
|
||||
|
||||
func (g guardedStorage) RenamePart(ctx context.Context, srcVolume, srcPath, dstVolume, dstPath string, meta []byte, skipParent string) error {
|
||||
if err := guardObjectPaths(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guardPaths(skipParent); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.RenamePart(ctx, srcVolume, srcPath, dstVolume, dstPath, meta, skipParent)
|
||||
}
|
||||
|
||||
func (g guardedStorage) CheckParts(ctx context.Context, volume, path string, fi FileInfo) (*CheckPartsResp, error) {
|
||||
if err := guardPaths(path, fi.DataDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := guardErasureParams(fi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.CheckParts(ctx, volume, path, fi)
|
||||
}
|
||||
|
||||
func (g guardedStorage) Delete(ctx context.Context, volume, path string, opts DeleteOptions) error {
|
||||
if err := guardPaths(path, opts.OldDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.Delete(ctx, volume, path, opts)
|
||||
}
|
||||
|
||||
func (g guardedStorage) VerifyFile(ctx context.Context, volume, path string, fi FileInfo) (*CheckPartsResp, error) {
|
||||
if err := guardPaths(path, fi.DataDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := guardErasureParams(fi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.VerifyFile(ctx, volume, path, fi)
|
||||
}
|
||||
|
||||
func (g guardedStorage) StatInfoFile(ctx context.Context, volume, path string, glob bool) ([]StatInfo, error) {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.StatInfoFile(ctx, volume, path, glob)
|
||||
}
|
||||
|
||||
// ReadParts is a read, so it gets guardPaths rather than guardObjectPaths: the
|
||||
// volume-root alias rule exists because rename and delete *relocate or destroy*
|
||||
// the root, whereas a read of it merely fails to find part.N. Applying the
|
||||
// stricter rule here would widen the false-positive surface for no gain.
|
||||
func (g guardedStorage) ReadParts(ctx context.Context, bucket string, partMetaPaths ...string) ([]*ObjectPartInfo, error) {
|
||||
if err := guardPaths(partMetaPaths...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.ReadParts(ctx, bucket, partMetaPaths...)
|
||||
}
|
||||
|
||||
func (g guardedStorage) CleanAbandonedData(ctx context.Context, volume, path string) error {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.CleanAbandonedData(ctx, volume, path)
|
||||
}
|
||||
|
||||
func (g guardedStorage) WriteAll(ctx context.Context, volume, path string, b []byte) error {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.WriteAll(ctx, volume, path, b)
|
||||
}
|
||||
|
||||
func (g guardedStorage) ReadAll(ctx context.Context, volume, path string) ([]byte, error) {
|
||||
if err := guardPaths(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.StorageAPI.ReadAll(ctx, volume, path)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory walks and scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (g guardedStorage) WalkDir(ctx context.Context, opts WalkDirOptions, wr io.Writer) error {
|
||||
// FilterPrefix and ForwardTo are only string-compared against readDir
|
||||
// output, they never reach a path join.
|
||||
if err := guardPaths(opts.Bucket, opts.BaseDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.StorageAPI.WalkDir(ctx, opts, wr)
|
||||
}
|
||||
|
||||
// NSScanner is the one StorageAPI method that reaches the filesystem without
|
||||
// passing through getVolDir: the scanner joins cache.Info.Name onto drivePath
|
||||
// directly (see scanFolder). It is therefore guarded here rather than at the
|
||||
// sink. Today an unregistered bucket name is also rejected further down by
|
||||
// globalBucketObjectLockSys, but that is a lookup in an unrelated subsystem,
|
||||
// not a containment boundary.
|
||||
func (g guardedStorage) NSScanner(ctx context.Context, cache dataUsageCache, updates chan<- dataUsageEntry, scanMode madmin.HealScanMode, shouldSleep func() bool) (dataUsageCache, error) {
|
||||
if err := guardPaths(cache.Info.Name); err != nil {
|
||||
// The caller blocks until updates is closed; NSScanner owns closing it.
|
||||
xioutil.SafeClose(updates)
|
||||
return cache, err
|
||||
}
|
||||
return g.StorageAPI.NSScanner(ctx, cache, updates, scanMode, shouldSleep)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio-go/v7/pkg/s3utils"
|
||||
)
|
||||
|
||||
// poisonStorage implements StorageAPI with a nil embedded interface, so any
|
||||
// call that reaches it panics. guardedStorage must never delegate a traversing
|
||||
// path to it.
|
||||
type poisonStorage struct {
|
||||
StorageAPI
|
||||
}
|
||||
|
||||
// volumeOnlyOrPathless lists the StorageAPI methods guardedStorage
|
||||
// deliberately does not override, with the reason each is safe.
|
||||
//
|
||||
// Adding a method to StorageAPI without either guarding it or adding it here
|
||||
// with a reason makes TestGuardedStorageCoversEveryPathMethod fail.
|
||||
var volumeOnlyOrPathless = map[string]string{
|
||||
// No filesystem path in the signature at all.
|
||||
"String": "identity", "IsOnline": "status", "LastConn": "status",
|
||||
"IsLocal": "topology", "Hostname": "topology", "Endpoint": "topology",
|
||||
"Close": "lifecycle", "GetDiskID": "identity", "SetDiskID": "identity",
|
||||
"Healing": "status", "GetDiskLoc": "topology", "DiskInfo": "no path field",
|
||||
"ListVols": "no argument",
|
||||
|
||||
// Volume-only. xlStorage.getVolDir validates the volume at the sink, which
|
||||
// also covers the peer-S3 callers that never pass through this wrapper.
|
||||
"MakeVol": "volume-only, guarded by getVolDir",
|
||||
"MakeVolBulk": "volume-only, guarded by getVolDir",
|
||||
"StatVol": "volume-only, guarded by getVolDir",
|
||||
"DeleteVol": "volume-only, guarded by getVolDir",
|
||||
}
|
||||
|
||||
// TestGuardedStorageCoversEveryPathMethod calls every StorageAPI method on a
|
||||
// guardedStorage whose embedded storage panics, passing "../evil" in every
|
||||
// string it can reach - including strings nested inside structs and slices,
|
||||
// which is where a hand-written check is most likely to miss one.
|
||||
//
|
||||
// A method that returns without panicking rejected the path. A method that
|
||||
// panics delegated it.
|
||||
func TestGuardedStorageCoversEveryPathMethod(t *testing.T) {
|
||||
g := reflect.ValueOf(guardedStorage{poisonStorage{}})
|
||||
iface := reflect.TypeOf((*StorageAPI)(nil)).Elem()
|
||||
|
||||
for i := range iface.NumMethod() {
|
||||
name := iface.Method(i).Name
|
||||
if reason, ok := volumeOnlyOrPathless[name]; ok {
|
||||
t.Logf("skipping %s: %s", name, reason)
|
||||
continue
|
||||
}
|
||||
m := g.MethodByName(name)
|
||||
if !m.IsValid() {
|
||||
t.Errorf("%s: not found on guardedStorage", name)
|
||||
continue
|
||||
}
|
||||
mt := m.Type()
|
||||
args := make([]reflect.Value, mt.NumIn())
|
||||
for j := range args {
|
||||
args[j] = poisonArg(t, mt.In(j))
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("%s delegated a traversing path to the underlying storage "+
|
||||
"- it is not guarded. Add a guardedStorage override, or add it to "+
|
||||
"volumeOnlyOrPathless with a reason. (%v)", name, r)
|
||||
}
|
||||
}()
|
||||
if mt.IsVariadic() {
|
||||
m.CallSlice(args)
|
||||
} else {
|
||||
m.Call(args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardChecksUnreachableFields pins the guard on path fields that exist on
|
||||
// the wire but that no handler currently reads, so an end-to-end test cannot
|
||||
// observe them. DeleteVersionHandler hardcodes `opts := DeleteOptions{}` and
|
||||
// discards p.Opts, which means DeleteOptions.OldDataDir - a value that reaches
|
||||
// renameAll() with no containment of its own - is unreachable by accident
|
||||
// rather than by design. If someone later plumbs p.Opts through, the guard is
|
||||
// already there; this test is what stops it from being removed as "dead".
|
||||
func TestGuardChecksUnreachableFields(t *testing.T) {
|
||||
g := guardedStorage{poisonStorage{}}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := g.DeleteVersion(ctx, "foo", "obj", FileInfo{}, false,
|
||||
DeleteOptions{OldDataDir: poisonPath}); !errors.Is(err, errFileAccessDenied) {
|
||||
t.Errorf("DeleteVersion with a traversing OldDataDir: got %v, want %v", err, errFileAccessDenied)
|
||||
}
|
||||
|
||||
errs := g.DeleteVersions(ctx, "foo", []FileInfoVersions{{Name: "obj"}},
|
||||
DeleteOptions{OldDataDir: poisonPath})
|
||||
if len(errs) != 1 || !errors.Is(errs[0], errFileAccessDenied) {
|
||||
t.Errorf("DeleteVersions with a traversing OldDataDir: got %v, want %v", errs, errFileAccessDenied)
|
||||
}
|
||||
|
||||
if err := g.Delete(ctx, "foo", "obj", DeleteOptions{OldDataDir: poisonPath}); !errors.Is(err, errFileAccessDenied) {
|
||||
t.Errorf("Delete with a traversing OldDataDir: got %v, want %v", err, errFileAccessDenied)
|
||||
}
|
||||
}
|
||||
|
||||
const poisonPath = "../evil"
|
||||
|
||||
func poisonArg(t *testing.T, typ reflect.Type) reflect.Value {
|
||||
t.Helper()
|
||||
switch typ {
|
||||
case reflect.TypeOf((*context.Context)(nil)).Elem():
|
||||
return reflect.ValueOf(context.Background())
|
||||
case reflect.TypeOf((*io.Writer)(nil)).Elem():
|
||||
return reflect.ValueOf(io.Discard)
|
||||
case reflect.TypeOf((*io.Reader)(nil)).Elem():
|
||||
return reflect.ValueOf(bytes.NewReader(nil))
|
||||
}
|
||||
if typ.Kind() == reflect.Chan {
|
||||
// NSScanner's updates channel: the guard is required to close it.
|
||||
return reflect.MakeChan(reflect.ChanOf(reflect.BothDir, typ.Elem()), 1).Convert(typ)
|
||||
}
|
||||
return poisonValue(typ, 0)
|
||||
}
|
||||
|
||||
// poisonValue builds a value of typ with every settable string set to
|
||||
// poisonPath, recursing into structs, slices and pointers.
|
||||
func poisonValue(typ reflect.Type, depth int) reflect.Value {
|
||||
v := reflect.New(typ).Elem()
|
||||
if depth > 4 {
|
||||
return v
|
||||
}
|
||||
switch typ.Kind() {
|
||||
case reflect.String:
|
||||
v.SetString(poisonPath)
|
||||
case reflect.Struct:
|
||||
for i := range typ.NumField() {
|
||||
f := v.Field(i)
|
||||
if !f.CanSet() {
|
||||
continue // unexported
|
||||
}
|
||||
// Skip self-referential and container types we cannot poison
|
||||
// meaningfully; the fields that matter are plain strings.
|
||||
switch f.Kind() {
|
||||
case reflect.Map, reflect.Chan, reflect.Func, reflect.Interface, reflect.UnsafePointer:
|
||||
continue
|
||||
}
|
||||
f.Set(poisonValue(f.Type(), depth+1))
|
||||
}
|
||||
case reflect.Slice:
|
||||
s := reflect.MakeSlice(typ, 1, 1)
|
||||
s.Index(0).Set(poisonValue(typ.Elem(), depth+1))
|
||||
v.Set(s)
|
||||
case reflect.Pointer:
|
||||
p := reflect.New(typ.Elem())
|
||||
p.Elem().Set(poisonValue(typ.Elem(), depth+1))
|
||||
v.Set(p)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestIsVolumeRootAlias(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// Collapse back to the volume directory.
|
||||
{"", true}, {"/", true}, {"//", true},
|
||||
// Backslash is platform-dependent; see TestIsVolumeRootAliasIsPlatformCorrect.
|
||||
// Whitespace is NOT a separator. path.Clean leaves it alone, so these
|
||||
// name real directories and are legal S3 object keys.
|
||||
{" ", false}, {" ", false}, {"\t", false}, {"\n", false},
|
||||
{" / ", false}, {"/ ", false},
|
||||
{"a", false}, {"/a", false}, {"..", false}, {" a ", false},
|
||||
{"obj/part.1", false},
|
||||
} {
|
||||
if got := isVolumeRootAlias(tc.path); got != tc.want {
|
||||
t.Errorf("isVolumeRootAlias(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// guardMayRefuse reports whether the guards are permitted to refuse a name that
|
||||
// IsValidObjectName accepts. On Unix the answer is never: no legal object name
|
||||
// is made up entirely of '/', so the invariant below carries NO exceptions.
|
||||
//
|
||||
// Stated in terms of the separator set rather than by calling isVolumeRootAlias,
|
||||
// so that widening that function cannot silently widen what the test forgives.
|
||||
// Two regressions have already hidden in exactly that gap: whitespace was once
|
||||
// counted as a separator (refusing the legal key " "), and an earlier version
|
||||
// of this helper excused every backslash-only name on every platform, which is
|
||||
// why the fuzzer could not see that bug at all.
|
||||
func guardMayRefuse(name string) bool {
|
||||
if runtime.GOOS != globalWindowsOSName {
|
||||
return false
|
||||
}
|
||||
// Mirrors the Windows branch of isVolumeRootAliasOn: separators plus the
|
||||
// characters Win32 strips from a component.
|
||||
return strings.Trim(name, "/\\ .") == ""
|
||||
}
|
||||
|
||||
func TestIsVolumeRootAliasIsPlatformCorrect(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
unix, windowsWant bool
|
||||
}{
|
||||
{"", true, true},
|
||||
{"/", true, true},
|
||||
{"//", true, true},
|
||||
// Backslash: an ordinary filename on Unix, a separator on Windows.
|
||||
{"\\", false, true},
|
||||
{"\\\\", false, true},
|
||||
{"/\\", false, true},
|
||||
// Space and period: ordinary filename characters on Unix, but stripped
|
||||
// from a component by Win32 normalisation, so a component made only of
|
||||
// them vanishes and the path resolves to the volume root.
|
||||
{" ", false, true}, {" ", false, true}, {" / ", false, true},
|
||||
{"...", false, true}, {". .", false, true},
|
||||
// Never an alias anywhere.
|
||||
{"\t", false, false}, {"\n", false, false},
|
||||
{"a", false, false}, {"/a", false, false}, {"a ", false, false},
|
||||
{" a", false, false}, {"a.", false, false},
|
||||
} {
|
||||
if got := isVolumeRootAliasOn(tc.path, false); got != tc.unix {
|
||||
t.Errorf("isVolumeRootAliasOn(%q, unix) = %v, want %v", tc.path, got, tc.unix)
|
||||
}
|
||||
if got := isVolumeRootAliasOn(tc.path, true); got != tc.windowsWant {
|
||||
t.Errorf("isVolumeRootAliasOn(%q, windows) = %v, want %v", tc.path, got, tc.windowsWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardAcceptsEveryLegalObjectName pins the invariant that actually matters
|
||||
// for availability: if S3 accepts a name, the guards must accept it too.
|
||||
//
|
||||
// A guard that rejects a legal object name breaks writes on every remote drive
|
||||
// simultaneously, which fails quorum - a worse outage than the vulnerability it
|
||||
// defends against. This caught a real regression: isVolumeRootAlias originally
|
||||
// treated whitespace as a separator, so a legal key of " " was refused on the
|
||||
// PutObject commit path.
|
||||
func TestGuardAcceptsEveryLegalObjectName(t *testing.T) {
|
||||
names := []string{
|
||||
// Whitespace, in every position. All legal in S3.
|
||||
" ", " ", "\t", "\n", "a b", " a", "a ", " a ", " / ", "/ ",
|
||||
// Dots that are not "." or ".." segments.
|
||||
"..foo", "foo..", ".hidden", "a/..b", "a/b..", "a.b/c..d",
|
||||
// "..." is a legal key on Unix and accepted; on Windows it is a volume-root
|
||||
// alias (Win32 strips trailing periods) and guardMayRefuse excuses it there.
|
||||
"part.1.meta", "...", "a/...",
|
||||
// Ordinary shapes.
|
||||
"a", "/a", "a/b/c", "obj/part.1", "__XLDIR__",
|
||||
"unicode/文件/名", "emoji/🙂", "pct%2e%2e/x",
|
||||
// Long-ish and punctuation-heavy.
|
||||
"a-b_c+d=e,f:g;h@i", "x!y'z(1)2*3",
|
||||
// Backslashes: ordinary filename characters on Unix.
|
||||
"\\", "\\\\", "/\\", "a\\b", "\\a", "a\\",
|
||||
}
|
||||
for _, name := range names {
|
||||
if !IsValidObjectName(name) {
|
||||
continue // S3 refuses it first; the guard is free to as well.
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := guardPaths(name); err != nil {
|
||||
t.Errorf("guardPaths(%q) = %v, but S3 accepts this object name", name, err)
|
||||
}
|
||||
if err := guardObjectPaths(name); err != nil {
|
||||
if guardMayRefuse(name) {
|
||||
t.Logf("allowed on this platform (%q): separator-only, addresses the volume root", name)
|
||||
return
|
||||
}
|
||||
t.Errorf("guardObjectPaths(%q) = %v, but S3 accepts this object name. "+
|
||||
"A guard that refuses a legal key fails writes on every drive at once.", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzGuardAcceptsLegalObjectNames searches for more of the above. The property
|
||||
// is one-directional on purpose: we assert the guards never refuse something S3
|
||||
// allows, and say nothing about names S3 already refuses.
|
||||
func FuzzGuardAcceptsLegalObjectNames(f *testing.F) {
|
||||
for _, seed := range []string{" ", " ", "\t", "a b", "..foo", "a/..b", "/", "", "\\", "\\\\\\", "a/../b"} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, name string) {
|
||||
if !IsValidObjectName(name) {
|
||||
return
|
||||
}
|
||||
if guardMayRefuse(name) {
|
||||
return
|
||||
}
|
||||
if err := guardPaths(name); err != nil {
|
||||
t.Fatalf("guardPaths(%q) = %v, but IsValidObjectName accepts it", name, err)
|
||||
}
|
||||
if err := guardObjectPaths(name); err != nil {
|
||||
t.Fatalf("guardObjectPaths(%q) = %v, but IsValidObjectName accepts it", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzGetVolDirAcceptsLegalBucketNames is the volume-axis twin of the object
|
||||
// invariant above. G-A adds hasBadPathComponent to getVolDir, so any bucket
|
||||
// name S3 accepts must survive it, or MakeBucket/HeadBucket/DeleteBucket start
|
||||
// failing for legitimate buckets across the whole cluster.
|
||||
func FuzzGetVolDirAcceptsLegalBucketNames(f *testing.F) {
|
||||
for _, seed := range []string{"bucket", "my-bucket", "a.b.c", "1234", "x--y", "..", "a..b"} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, name string) {
|
||||
if s3utils.CheckValidBucketName(name) != nil {
|
||||
return // S3 refuses it first; getVolDir is free to as well.
|
||||
}
|
||||
if hasBadPathComponent(name) {
|
||||
t.Fatalf("getVolDir would reject %q, but s3utils.CheckValidBucketName accepts it", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetVolDirAcceptsReservedVolumes covers the volume names the server uses
|
||||
// internally, which are not buckets and so are not covered by the fuzz above.
|
||||
func TestGetVolDirAcceptsReservedVolumes(t *testing.T) {
|
||||
for _, vol := range []string{
|
||||
minioMetaBucket, minioMetaTmpBucket, minioMetaTmpDeletedBucket,
|
||||
minioMetaMultipartBucket, minioReservedBucket,
|
||||
pathJoin(minioMetaBucket, bucketMetaPrefix),
|
||||
pathJoin(minioMetaBucket, bucketMetaPrefix, deletedBucketsPrefix, "some-bucket"),
|
||||
} {
|
||||
if hasBadPathComponent(vol) {
|
||||
t.Errorf("getVolDir would reject the reserved volume %q", vol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardPathsAcceptsReservedNames(t *testing.T) {
|
||||
// Reserved volumes and directories contain dot-prefixed segments. Only an
|
||||
// exact "." or ".." segment is a traversal.
|
||||
for _, p := range []string{
|
||||
"", minioMetaBucket, minioMetaTmpBucket, minioMetaTmpDeletedBucket,
|
||||
minioMetaMultipartBucket, ".deleted", "obj/part.1.meta",
|
||||
"a.b/c..d", "__XLDIR__", "bucket/.metacache/x.s2",
|
||||
} {
|
||||
if err := guardPaths(p); err != nil {
|
||||
t.Errorf("guardPaths(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range traversalPaths {
|
||||
if err := guardPaths(p); err == nil {
|
||||
t.Errorf("guardPaths(%q) = nil, want %v", p, errFileAccessDenied)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,10 @@ var (
|
||||
storageListDirRPC = grid.NewStream[*grid.MSS, grid.NoPayload, *ListDirResult](grid.HandlerListDir, grid.NewMSS, nil, func() *ListDirResult { return &ListDirResult{} }).WithOutCapacity(1)
|
||||
)
|
||||
|
||||
// getStorageViaEndpoint returns the drive UNGUARDED. It is for local callers
|
||||
// only, whose arguments the server itself constructed. Anything serving a
|
||||
// remote peer must go through storageRESTServer.getStorage(), which wraps this
|
||||
// in guardedStorage to reject traversal in wire-supplied paths.
|
||||
func getStorageViaEndpoint(endpoint Endpoint) StorageAPI {
|
||||
globalLocalDrivesMu.RLock()
|
||||
defer globalLocalDrivesMu.RUnlock()
|
||||
@@ -85,7 +89,18 @@ func getStorageViaEndpoint(endpoint Endpoint) StorageAPI {
|
||||
}
|
||||
|
||||
func (s *storageRESTServer) getStorage() StorageAPI {
|
||||
return getStorageViaEndpoint(s.endpoint)
|
||||
st := getStorageViaEndpoint(s.endpoint)
|
||||
if st == nil {
|
||||
// Must stay an untyped nil. IsAuthValid and checkID compare the result
|
||||
// against nil before authenticating, and a nil interface wrapped in a
|
||||
// value struct does not compare equal to nil - that would turn an
|
||||
// unauthenticated request against a drive that has not come up yet
|
||||
// into a nil dereference.
|
||||
return nil
|
||||
}
|
||||
// Reject traversal in paths carried by request bodies and grid RPC frames,
|
||||
// neither of which the global HTTP middleware can see. See guardedStorage.
|
||||
return guardedStorage{st}
|
||||
}
|
||||
|
||||
func (s *storageRESTServer) writeErrorResponse(w http.ResponseWriter, err error) {
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
// Copyright (c) 2015-2026 MinIO, Inc.
|
||||
//
|
||||
// This file is part of MinIO Object Storage stack
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
// Paths that must never be accepted from an internode payload. Each is a
|
||||
// distinct evasion of the segment scanner: forward slash, backslash (Windows
|
||||
// resolves these, path.Clean does not), whitespace padding, and single-dot.
|
||||
var traversalPaths = []string{
|
||||
"../evil",
|
||||
"../../evil",
|
||||
"a/../../evil",
|
||||
"..\\evil",
|
||||
"a\\..\\..\\evil",
|
||||
" .. /evil",
|
||||
"../",
|
||||
"./../evil",
|
||||
}
|
||||
|
||||
// Paths that alias the volume root itself: pathJoin collapses each of these
|
||||
// back to the volume directory, so renaming or deleting one relocates or
|
||||
// destroys the whole volume, with no ".." required.
|
||||
//
|
||||
// Only the platform-independent forms live here. Whitespace does NOT belong:
|
||||
// path.Clean leaves spaces alone, so " " names a real directory inside the
|
||||
// volume and is a legal S3 object key. Backslash forms do not belong either:
|
||||
// they collapse on Windows but name an ordinary file on Unix. Both classes are
|
||||
// covered by TestIsVolumeRootAliasIsPlatformCorrect and, from the other
|
||||
// direction, by TestGuardAcceptsEveryLegalObjectName.
|
||||
var volumeRootAliases = []string{"", "/", "//"}
|
||||
|
||||
// sentinels plants files we can prove were neither read nor removed.
|
||||
type sentinels struct {
|
||||
drive string // drive root
|
||||
outside string // file outside the drive root entirely
|
||||
sibling string // file in a sibling volume
|
||||
legit string // a legitimate object inside "foo"
|
||||
partDir string // a readable part dir in the sibling volume
|
||||
outsideRe string // path a rename/write would land on, outside the drive
|
||||
}
|
||||
|
||||
func plantSentinels(t *testing.T, drive string) *sentinels {
|
||||
t.Helper()
|
||||
s := &sentinels{
|
||||
drive: drive,
|
||||
outside: filepath.Join(filepath.Dir(drive), "sentinel-outside.txt"),
|
||||
sibling: filepath.Join(drive, "bar", "sentinel-sibling.txt"),
|
||||
legit: filepath.Join(drive, "foo", "legit.txt"),
|
||||
partDir: filepath.Join(drive, "bar", "obj"),
|
||||
outsideRe: filepath.Join(filepath.Dir(drive), "sentinel-landing.txt"),
|
||||
}
|
||||
mustWrite(t, s.outside, "OUTSIDE-SECRET")
|
||||
t.Cleanup(func() { os.Remove(s.outside); os.Remove(s.outsideRe) })
|
||||
mustWrite(t, s.sibling, "SIBLING-SECRET")
|
||||
mustWrite(t, s.legit, "LEGIT")
|
||||
|
||||
if err := os.MkdirAll(s.partDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(s.partDir, "part.1"), "data")
|
||||
blob, err := (&ObjectPartInfo{Number: 1, Size: 4, ETag: "SIBLING-ETAG"}).MarshalMsg(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.partDir, "part.1.meta"), blob, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertIntact fails if any sentinel was removed, modified, or if a file
|
||||
// appeared where a traversal would have landed.
|
||||
func (s *sentinels) assertIntact(t *testing.T, op string) {
|
||||
t.Helper()
|
||||
for _, f := range []struct{ path, want string }{
|
||||
{s.outside, "OUTSIDE-SECRET"},
|
||||
{s.sibling, "SIBLING-SECRET"},
|
||||
{s.legit, "LEGIT"},
|
||||
} {
|
||||
got, err := os.ReadFile(f.path)
|
||||
if err != nil {
|
||||
t.Errorf("%s: sentinel %s was destroyed: %v", op, f.path, err)
|
||||
continue
|
||||
}
|
||||
if string(got) != f.want {
|
||||
t.Errorf("%s: sentinel %s was modified: got %q want %q", op, f.path, got, f.want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(s.outsideRe); err == nil {
|
||||
t.Errorf("%s: a file was created outside the drive root at %s", op, s.outsideRe)
|
||||
}
|
||||
for _, vol := range []string{"foo", "bar"} {
|
||||
st, err := os.Stat(filepath.Join(s.drive, vol))
|
||||
if err != nil || !st.IsDir() {
|
||||
t.Errorf("%s: volume %q no longer exists as a directory (err=%v)", op, vol, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertDenied requires the specific sentinel error, not merely "some error".
|
||||
// An operation on a nonexistent path fails anyway, so a bare err != nil check
|
||||
// passes even when the guard is absent.
|
||||
func assertDenied(t *testing.T, op string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected %v, got nil", op, errFileAccessDenied)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, errFileAccessDenied) {
|
||||
t.Errorf("%s: expected %v, got %v (%T)", op, errFileAccessDenied, err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageRESTTraversalRejected drives the real REST/grid client against a
|
||||
// real xlStorage and proves that no internode payload can read, write, move or
|
||||
// delete anything outside its own volume.
|
||||
//
|
||||
// NOTE: the grid.SetupTestGrid harness mounts a bare mux router with none of
|
||||
// globalMiddlewares, so a green run says nothing about the production HTTP
|
||||
// query-argument path (which setRequestValidityMiddleware already covers). It
|
||||
// measures the storage-layer guards and nothing else, which is the point.
|
||||
func TestStorageRESTTraversalRejected(t *testing.T) {
|
||||
restClient := newStorageRESTHTTPServerClient(t)
|
||||
drive := globalLocalSetDrives[0][0][0].Endpoint().Path
|
||||
s := plantSentinels(t, drive)
|
||||
ctx := t.Context()
|
||||
|
||||
badFI := func(dataDir string) FileInfo {
|
||||
return FileInfo{Volume: "foo", Name: "obj", DataDir: dataDir, ModTime: UTCNow()}
|
||||
}
|
||||
|
||||
// Every operation that accepts a path from the wire, driven with each
|
||||
// traversal form. The op must be denied AND must not have touched disk.
|
||||
for _, p := range traversalPaths {
|
||||
ops := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{"ReadAll", func() error { _, err := restClient.ReadAll(ctx, "foo", p); return err }},
|
||||
{"WriteAll", func() error { return restClient.WriteAll(ctx, "foo", p, []byte("PWNED")) }},
|
||||
{"ReadXL", func() error { _, err := restClient.ReadXL(ctx, "foo", p, true); return err }},
|
||||
{"Delete", func() error {
|
||||
return restClient.Delete(ctx, "foo", p, DeleteOptions{Recursive: true, Immediate: true})
|
||||
}},
|
||||
{"DeleteBulk", func() error { return restClient.DeleteBulk(ctx, "foo", p) }},
|
||||
{"ReadParts", func() error { _, err := restClient.ReadParts(ctx, "foo", p); return err }},
|
||||
{"StatInfoFile", func() error { _, err := restClient.StatInfoFile(ctx, "foo", p, false); return err }},
|
||||
{"CleanAbandonedData", func() error { return restClient.CleanAbandonedData(ctx, "foo", p) }},
|
||||
{"ListDir", func() error { _, err := restClient.ListDir(ctx, "", "foo", p, -1); return err }},
|
||||
{"RenameFile-src", func() error { return restClient.RenameFile(ctx, "foo", p, "foo", "dst.txt") }},
|
||||
{"RenameFile-dst", func() error { return restClient.RenameFile(ctx, "foo", "legit.txt", "foo", p) }},
|
||||
{"RenamePart-src", func() error {
|
||||
return restClient.RenamePart(ctx, "foo", p, "foo", "dst.txt", nil, "")
|
||||
}},
|
||||
{"RenamePart-dst", func() error {
|
||||
return restClient.RenamePart(ctx, "foo", "legit.txt", "foo", p, nil, "")
|
||||
}},
|
||||
{"RenamePart-skipParent", func() error {
|
||||
return restClient.RenamePart(ctx, "foo", "legit.txt", "foo", "dst.txt", nil, p)
|
||||
}},
|
||||
{"CheckParts", func() error { _, err := restClient.CheckParts(ctx, "foo", p, badFI("")); return err }},
|
||||
{"CheckParts-DataDir", func() error {
|
||||
_, err := restClient.CheckParts(ctx, "foo", "obj", badFI(p))
|
||||
return err
|
||||
}},
|
||||
{"VerifyFile", func() error { _, err := restClient.VerifyFile(ctx, "foo", p, badFI("")); return err }},
|
||||
{"VerifyFile-DataDir", func() error {
|
||||
_, err := restClient.VerifyFile(ctx, "foo", "obj", badFI(p))
|
||||
return err
|
||||
}},
|
||||
{"WriteMetadata", func() error { return restClient.WriteMetadata(ctx, "", "foo", p, badFI("")) }},
|
||||
{"UpdateMetadata", func() error {
|
||||
return restClient.UpdateMetadata(ctx, "foo", p, badFI(""), UpdateMetadataOpts{})
|
||||
}},
|
||||
{"DeleteVersion", func() error {
|
||||
return restClient.DeleteVersion(ctx, "foo", p, badFI(""), false, DeleteOptions{})
|
||||
}},
|
||||
|
||||
// Nested path-bearing fields, poisoned one at a time. A method that
|
||||
// validates its `path` argument but forgets one of these still
|
||||
// passes every check above, so each needs its own case.
|
||||
{"WriteMetadata-DataDir", func() error {
|
||||
return restClient.WriteMetadata(ctx, "", "foo", "obj", badFI(p))
|
||||
}},
|
||||
{"UpdateMetadata-DataDir", func() error {
|
||||
return restClient.UpdateMetadata(ctx, "foo", "obj", badFI(p), UpdateMetadataOpts{})
|
||||
}},
|
||||
{"DeleteVersion-DataDir", func() error {
|
||||
return restClient.DeleteVersion(ctx, "foo", "obj", badFI(p), false, DeleteOptions{})
|
||||
}},
|
||||
// DeleteOptions.OldDataDir is guarded too, but cannot be exercised
|
||||
// from here: DeleteVersionHandler hardcodes `opts := DeleteOptions{}`
|
||||
// and never reads the wire value, so the field is unreachable today.
|
||||
// That is an accidental mitigation, not a control - see
|
||||
// TestGuardChecksUnreachableFields for the unit-level assertion that
|
||||
// the guard is ready if anyone ever plumbs it through.
|
||||
{"RenameData-srcPath", func() error {
|
||||
_, err := restClient.RenameData(ctx, "foo", p, badFI(""), "bar", "dst", RenameOptions{})
|
||||
return err
|
||||
}},
|
||||
{"RenameData-dstPath", func() error {
|
||||
_, err := restClient.RenameData(ctx, "foo", "src", badFI(""), "bar", p, RenameOptions{})
|
||||
return err
|
||||
}},
|
||||
{"RenameData-DataDir", func() error {
|
||||
_, err := restClient.RenameData(ctx, "foo", "src", badFI(p), "bar", "dst", RenameOptions{})
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, op := range ops {
|
||||
t.Run(op.name+"/"+p, func(t *testing.T) {
|
||||
assertDenied(t, op.name, op.run())
|
||||
s.assertIntact(t, op.name)
|
||||
})
|
||||
}
|
||||
|
||||
// The nested DataDir of each version is a separate field from the
|
||||
// per-object Name; poison them independently so neither can regress
|
||||
// behind the other.
|
||||
t.Run("DeleteVersions-VersionDataDir/"+p, func(t *testing.T) {
|
||||
errs := restClient.DeleteVersions(ctx, "foo",
|
||||
[]FileInfoVersions{{Name: "obj", Versions: []FileInfo{{Name: "obj", DataDir: p}}}},
|
||||
DeleteOptions{})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected 1 error, got %d", len(errs))
|
||||
}
|
||||
assertDenied(t, "DeleteVersions-VersionDataDir", errs[0])
|
||||
s.assertIntact(t, "DeleteVersions-VersionDataDir")
|
||||
})
|
||||
|
||||
t.Run("DeleteVersions/"+p, func(t *testing.T) {
|
||||
errs := restClient.DeleteVersions(ctx, "foo",
|
||||
[]FileInfoVersions{{Name: p, Versions: []FileInfo{{Name: p}}}}, DeleteOptions{})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected 1 error, got %d", len(errs))
|
||||
}
|
||||
assertDenied(t, "DeleteVersions", errs[0])
|
||||
s.assertIntact(t, "DeleteVersions")
|
||||
})
|
||||
|
||||
// WalkDir and NSScanner take their paths inside an options/cache struct
|
||||
// rather than as arguments, which is exactly where a per-handler check
|
||||
// tends to miss them.
|
||||
t.Run("WalkDir/"+p, func(t *testing.T) {
|
||||
for _, opts := range []WalkDirOptions{
|
||||
{Bucket: p, BaseDir: "obj"},
|
||||
{Bucket: "foo", BaseDir: p},
|
||||
} {
|
||||
if err := restClient.WalkDir(ctx, opts, io.Discard); err == nil {
|
||||
t.Errorf("WalkDir(%+v) returned nil", opts)
|
||||
}
|
||||
}
|
||||
s.assertIntact(t, "WalkDir")
|
||||
})
|
||||
|
||||
t.Run("NSScanner/"+p, func(t *testing.T) {
|
||||
cache := dataUsageCache{Info: dataUsageCacheInfo{Name: p}}
|
||||
updates := make(chan dataUsageEntry, 1)
|
||||
if _, err := restClient.NSScanner(ctx, cache, updates, madmin.HealNormalScan, nil); err == nil {
|
||||
t.Errorf("NSScanner(cache.Info.Name=%q) returned nil", p)
|
||||
}
|
||||
s.assertIntact(t, "NSScanner")
|
||||
})
|
||||
|
||||
t.Run("ReadAll-volume/"+p, func(t *testing.T) {
|
||||
// Traversal smuggled through the volume argument rather than the path.
|
||||
buf, err := restClient.ReadAll(ctx, p, "sentinel-outside.txt")
|
||||
if err == nil {
|
||||
t.Errorf("ReadAll with volume %q: expected error, got nil (read %d bytes)", p, len(buf))
|
||||
}
|
||||
if string(buf) == "OUTSIDE-SECRET" {
|
||||
t.Errorf("ReadAll with volume %q leaked a file outside the drive root", p)
|
||||
}
|
||||
s.assertIntact(t, "ReadAll-volume")
|
||||
})
|
||||
}
|
||||
|
||||
// Volume-root aliases: no ".." involved, but a rename or bulk delete of the
|
||||
// volume root relocates or destroys the entire volume.
|
||||
for _, p := range volumeRootAliases {
|
||||
t.Run("DeleteBulk-root/"+p, func(t *testing.T) {
|
||||
assertDenied(t, "DeleteBulk", restClient.DeleteBulk(ctx, "foo", p))
|
||||
s.assertIntact(t, "DeleteBulk-root")
|
||||
})
|
||||
t.Run("RenameFile-root/"+p, func(t *testing.T) {
|
||||
assertDenied(t, "RenameFile", restClient.RenameFile(ctx, "foo", p, "bar", "captured"))
|
||||
s.assertIntact(t, "RenameFile-root")
|
||||
})
|
||||
}
|
||||
|
||||
// A batch mixing a legitimate target with a malicious one must delete
|
||||
// neither -- validate the whole set before acting on any of it.
|
||||
t.Run("MixedBatch", func(t *testing.T) {
|
||||
assertDenied(t, "DeleteBulk-mixed",
|
||||
restClient.DeleteBulk(ctx, "foo", "legit.txt", "../bar/sentinel-sibling.txt"))
|
||||
s.assertIntact(t, "DeleteBulk-mixed")
|
||||
})
|
||||
|
||||
t.Run("ReadParts-mixed", func(t *testing.T) {
|
||||
_, err := restClient.ReadParts(ctx, "foo", "legit.txt", "../bar/obj/part.1.meta")
|
||||
assertDenied(t, "ReadParts-mixed", err)
|
||||
s.assertIntact(t, "ReadParts-mixed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStorageRESTLegitimateOpsStillWork is the positive control: the guards must
|
||||
// not reject anything the cluster does in normal operation.
|
||||
func TestStorageRESTLegitimateOpsStillWork(t *testing.T) {
|
||||
restClient := newStorageRESTHTTPServerClient(t)
|
||||
drive := globalLocalSetDrives[0][0][0].Endpoint().Path
|
||||
ctx := t.Context()
|
||||
|
||||
mustWrite(t, filepath.Join(drive, "foo", "a.txt"), "HELLO")
|
||||
mustWrite(t, filepath.Join(drive, minioMetaBucket, "tmp", "sys.txt"), "SYS")
|
||||
|
||||
if err := restClient.WriteAll(ctx, "foo", "written.txt", []byte("DATA")); err != nil {
|
||||
t.Fatalf("WriteAll on a legitimate path: %v", err)
|
||||
}
|
||||
if b, err := restClient.ReadAll(ctx, "foo", "written.txt"); err != nil || string(b) != "DATA" {
|
||||
t.Fatalf("ReadAll on a legitimate path: %q %v", b, err)
|
||||
}
|
||||
// Reserved volumes contain dot-prefixed segments (".minio.sys", ".trash")
|
||||
// which must remain acceptable -- only exact "." and ".." segments are bad.
|
||||
if b, err := restClient.ReadAll(ctx, minioMetaBucket, "tmp/sys.txt"); err != nil || string(b) != "SYS" {
|
||||
t.Fatalf("ReadAll on %s: %q %v", minioMetaBucket, b, err)
|
||||
}
|
||||
if err := restClient.RenameFile(ctx, "foo", "a.txt", "foo", "b.txt"); err != nil {
|
||||
t.Fatalf("RenameFile on legitimate paths: %v", err)
|
||||
}
|
||||
if _, err := restClient.ListDir(ctx, "", "foo", "", -1); err != nil {
|
||||
t.Fatalf("ListDir on the volume root is legitimate: %v", err)
|
||||
}
|
||||
if err := restClient.DeleteBulk(ctx, "foo", "b.txt"); err != nil {
|
||||
t.Fatalf("DeleteBulk on a legitimate path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(drive, "foo", "b.txt")); err == nil {
|
||||
t.Fatal("DeleteBulk on a legitimate path did not delete")
|
||||
}
|
||||
|
||||
// Object keys that look like separators or path components but are not.
|
||||
// A whitespace-only key is legal in S3 and is committed through the rename
|
||||
// path on PutObject, so a guard that refuses it fails the write on every
|
||||
// remote drive at once and breaks quorum. This is a real regression that
|
||||
// shipped in an earlier draft of the guard.
|
||||
oddKeys := []string{" ", " ", "\t", "a b", " lead", "trail ", "..foo", "foo..", "a/..b"}
|
||||
if runtime.GOOS != globalWindowsOSName {
|
||||
// Backslash is an ordinary filename character off Windows.
|
||||
oddKeys = append(oddKeys, "\\", "\\\\", "a\\b", "/\\")
|
||||
}
|
||||
for _, key := range oddKeys {
|
||||
if !IsValidObjectName(key) {
|
||||
t.Fatalf("test bug: %q is not a legal object name", key)
|
||||
}
|
||||
if err := restClient.WriteAll(ctx, "foo", key, []byte("ODD")); err != nil {
|
||||
t.Errorf("WriteAll(%q) on a legal object key: %v", key, err)
|
||||
continue
|
||||
}
|
||||
if b, err := restClient.ReadAll(ctx, "foo", key); err != nil || string(b) != "ODD" {
|
||||
t.Errorf("ReadAll(%q) on a legal object key: %q %v", key, b, err)
|
||||
}
|
||||
// The rename path is what PutObject uses to commit.
|
||||
if err := restClient.RenameFile(ctx, "foo", key, "foo", key+"-renamed"); err != nil {
|
||||
t.Errorf("RenameFile(%q) on a legal object key: %v", key, err)
|
||||
continue
|
||||
}
|
||||
if err := restClient.DeleteBulk(ctx, "foo", key+"-renamed"); err != nil {
|
||||
t.Errorf("DeleteBulk(%q) on a legal object key: %v", key+"-renamed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeerS3VolumeTraversalRejected covers the peer-S3 bucket RPCs, which reach
|
||||
// local drives through globalLocalDrivesMap and never pass through
|
||||
// storageRESTServer.getStorage(). Only the getVolDir guard protects them.
|
||||
func TestPeerS3VolumeTraversalRejected(t *testing.T) {
|
||||
newStorageRESTHTTPServerClient(t)
|
||||
drive := globalLocalSetDrives[0][0][0].Endpoint().Path
|
||||
ctx := t.Context()
|
||||
|
||||
victim := filepath.Join(filepath.Dir(drive), "peer-victim")
|
||||
mustWrite(t, filepath.Join(victim, "nested", "important.txt"), "IMPORTANT")
|
||||
t.Cleanup(func() { os.RemoveAll(victim) })
|
||||
|
||||
for _, p := range traversalPaths {
|
||||
t.Run("DeleteBucket/"+p, func(t *testing.T) {
|
||||
// Force:true reaches moveToTrash(volumeDir, recursive, immediate).
|
||||
if err := deleteBucketLocal(ctx, p+"/peer-victim", DeleteBucketOptions{Force: true}); err == nil {
|
||||
t.Errorf("deleteBucketLocal(%q) returned nil", p)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(victim, "nested", "important.txt")); err != nil {
|
||||
t.Errorf("deleteBucketLocal(%q) destroyed a tree outside the drive root: %v", p, err)
|
||||
}
|
||||
})
|
||||
t.Run("MakeBucket/"+p, func(t *testing.T) {
|
||||
created := filepath.Join(filepath.Dir(drive), "peer-created")
|
||||
t.Cleanup(func() { os.RemoveAll(created) })
|
||||
if err := makeBucketLocal(ctx, p+"/peer-created", MakeBucketOptions{}); err == nil {
|
||||
t.Errorf("makeBucketLocal(%q) returned nil", p)
|
||||
}
|
||||
if _, err := os.Stat(created); err == nil {
|
||||
t.Errorf("makeBucketLocal(%q) created a directory outside the drive root", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckPartsMalformedErasure covers a remote node kill that is not a
|
||||
// traversal: CheckParts hands a wire-supplied FileInfo to ShardFileSize, which
|
||||
// divided by ErasureInfo.BlockSize with no validity check. The call runs inside
|
||||
// xioutil.WithDeadline, i.e. a bare goroutine, so the resulting integer
|
||||
// divide-by-zero panic cannot be recovered by the grid or net/http handlers --
|
||||
// it terminates the process. If this test regresses it does not fail, it
|
||||
// crashes the whole run.
|
||||
func TestCheckPartsMalformedErasure(t *testing.T) {
|
||||
restClient := newStorageRESTHTTPServerClient(t)
|
||||
drive := globalLocalSetDrives[0][0][0].Endpoint().Path
|
||||
ctx := t.Context()
|
||||
|
||||
// Not panicking is the floor, not the bar. ShardFileSize returns 0 for these,
|
||||
// and checkPart's "st.Size() < expectedSize" then succeeds for *any* file
|
||||
// that exists - including a truncated shard - so a request that got this far
|
||||
// would come back reporting every part intact. The boundary must refuse it
|
||||
// outright, which is what errFileCorrupt asserts here.
|
||||
for _, fi := range []FileInfo{
|
||||
{Volume: "foo", Name: "obj", Parts: []ObjectPartInfo{{Number: 1, Size: 4}}},
|
||||
// Deleted short-circuits FileInfo.IsValid() to true, so an IsValid()
|
||||
// guard would not have caught this one.
|
||||
{Volume: "foo", Name: "obj", Deleted: true, Parts: []ObjectPartInfo{{Number: 1, Size: 4}}},
|
||||
{Volume: "foo", Name: "obj", Parts: []ObjectPartInfo{{Number: 1, Size: 4}},
|
||||
Erasure: ErasureInfo{DataBlocks: 4}}, // BlockSize still zero
|
||||
{Volume: "foo", Name: "obj", Parts: []ObjectPartInfo{{Number: 1, Size: 4}},
|
||||
Erasure: ErasureInfo{BlockSize: blockSizeV2}}, // DataBlocks still zero
|
||||
} {
|
||||
if _, err := restClient.CheckParts(ctx, "foo", "obj", fi); !errors.Is(err, errFileCorrupt) {
|
||||
t.Errorf("CheckParts(%+v): got %v, want %v", fi.Erasure, err, errFileCorrupt)
|
||||
}
|
||||
if _, err := restClient.VerifyFile(ctx, "foo", "obj", fi); !errors.Is(err, errFileCorrupt) {
|
||||
t.Errorf("VerifyFile(%+v): got %v, want %v", fi.Erasure, err, errFileCorrupt)
|
||||
}
|
||||
}
|
||||
|
||||
// A part of zero length says nothing about the erasure parameters, so it
|
||||
// must not be swept up by the rule above.
|
||||
zeroPart := FileInfo{Volume: "foo", Name: "obj", Parts: []ObjectPartInfo{{Number: 1, Size: 0}}}
|
||||
if _, err := restClient.CheckParts(ctx, "foo", "obj", zeroPart); errors.Is(err, errFileCorrupt) {
|
||||
t.Errorf("CheckParts with a zero-length part was rejected as corrupt")
|
||||
}
|
||||
|
||||
// A NEGATIVE part size is the sharper case, and the one an ">0" test misses.
|
||||
// ShardFileSize returns 0 for it whether or not the erasure parameters are
|
||||
// usable (numShards and lastShardSize both floor to zero), so checkPart's
|
||||
// "st.Size() < expectedSize" is false for any file that exists and the part
|
||||
// is reported healthy. With a real short part planted on disk, a guard that
|
||||
// only rejects Size > 0 lets malformed metadata launder a truncated shard
|
||||
// into a clean bill of health.
|
||||
partDir := filepath.Join(drive, "foo", "negobj")
|
||||
if err := os.MkdirAll(partDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(partDir, "part.1"), "tiny")
|
||||
|
||||
for _, e := range []ErasureInfo{
|
||||
{}, // unusable parameters
|
||||
// Fully valid parameters. This is the sharper case: the FileInfo passes
|
||||
// FileInfo.IsValid(), the very check healing uses to decide the metadata
|
||||
// is trustworthy, and the negative size still lands on a zero expected
|
||||
// shard size. Valid erasure parameters do not save you here.
|
||||
{DataBlocks: 2, ParityBlocks: 2, BlockSize: blockSizeV2, Index: 1, Distribution: []int{1, 2, 3, 4}},
|
||||
} {
|
||||
fi := FileInfo{
|
||||
Volume: "foo", Name: "negobj", Erasure: e,
|
||||
Parts: []ObjectPartInfo{{Number: 1, Size: -2}},
|
||||
}
|
||||
if e.DataBlocks > 0 && !fi.IsValid() {
|
||||
t.Fatal("test bug: the second case must satisfy FileInfo.IsValid()")
|
||||
}
|
||||
resp, err := restClient.CheckParts(ctx, "foo", "negobj", fi)
|
||||
if !errors.Is(err, errFileCorrupt) {
|
||||
t.Errorf("CheckParts with a negative part size (erasure %+v): got err=%v resp=%v, want %v",
|
||||
e, err, resp, errFileCorrupt)
|
||||
if resp != nil && len(resp.Results) > 0 && resp.Results[0] == checkPartSuccess {
|
||||
t.Errorf(" -> and it reported the part HEALTHY, which is the actual damage")
|
||||
}
|
||||
}
|
||||
if _, err := restClient.VerifyFile(ctx, "foo", "negobj", fi); !errors.Is(err, errFileCorrupt) {
|
||||
t.Errorf("VerifyFile with a negative part size (erasure %+v): got %v, want %v", e, err, errFileCorrupt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +788,22 @@ func (s *xlStorage) getVolDir(volume string) (string, error) {
|
||||
if volume == "" || volume == "." || volume == ".." {
|
||||
return "", errVolumeNotFound
|
||||
}
|
||||
// Reject traversal smuggled inside the volume name itself, e.g. "../" or
|
||||
// "..\", which the equality checks above do not catch.
|
||||
//
|
||||
// This must be evaluated on the raw argument and never on the joined
|
||||
// result: pathJoin() below runs path.Clean() against an absolute
|
||||
// drivePath, and Clean() *erases* leading ".." on an absolute path
|
||||
// ("/drive/../../etc" becomes "/etc"), so a check placed after the join
|
||||
// would silently accept an escaped path.
|
||||
//
|
||||
// This is also the only containment control covering callers that never
|
||||
// reach storageRESTServer.getStorage() - notably the peer-S3 bucket RPCs
|
||||
// (MakeBucket/HeadBucket/DeleteBucket/HealBucket), which drive
|
||||
// MakeVol/StatVol/DeleteVol straight off globalLocalDrivesMap.
|
||||
if hasBadPathComponent(volume) {
|
||||
return "", errVolumeNotFound
|
||||
}
|
||||
volumeDir := pathJoin(s.drivePath, volume)
|
||||
return volumeDir, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user