286 lines
6.1 KiB
Markdown
286 lines
6.1 KiB
Markdown
# Filesystem Security Rules
|
|
|
|
Filesystem vulnerabilities can lead to unauthorized file access, data leakage, and denial-of-service attacks.
|
|
|
|
**Rules:**
|
|
|
|
1. User-controlled file paths MUST be confined to an allowed root.
|
|
2. `os.Root` SHOULD be used for scoped file access (Go 1.24+).
|
|
3. Zip extraction MUST check for ZipSlip path traversal.
|
|
4. Temporary files MUST use `os.CreateTemp` — NEVER predictable names.
|
|
5. File permissions MUST be restrictive (0600 for secrets, 0750 for directories).
|
|
|
|
---
|
|
|
|
## Directory Traversal — High
|
|
|
|
Paths like `../../etc/passwd` access files outside intended directory.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
filepath := filepath.Join("/var/www", filename) // DON'T
|
|
http.ServeFile(w, r, filepath)
|
|
```
|
|
|
|
**Good (Go 1.24+) — use `os.Root` for safe, scoped directory access:**
|
|
|
|
```go
|
|
root, err := os.OpenRoot("/var/www")
|
|
if err != nil { return err }
|
|
defer root.Close()
|
|
f, err := root.Open(filename) // cannot escape root directory
|
|
```
|
|
|
|
`os.Root` prevents ordinary path traversal at the OS level. All operations (`Open`, `Create`, `Stat`, `OpenFile`, etc.) are confined to the root directory, and symlinks that resolve outside the root are rejected. It is not a full sandbox: it does not by itself block bind mounts, special device files, or all `/proc`-style filesystem behavior. For archive extraction and uploads, still reject special files and choose a root without attacker-controlled mounts.
|
|
|
|
**Good (pre-Go 1.24 fallback):**
|
|
|
|
```go
|
|
func safeJoin(baseDir, userPath string) (string, error) {
|
|
if userPath == "" || filepath.IsAbs(userPath) || !filepath.IsLocal(userPath) {
|
|
return "", errors.New("invalid relative path")
|
|
}
|
|
|
|
full := filepath.Join(baseDir, userPath)
|
|
|
|
rel, err := filepath.Rel(baseDir, full)
|
|
if err != nil {
|
|
return "", fmt.Errorf("checking path: %w", err)
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", errors.New("path escapes base directory")
|
|
}
|
|
|
|
return full, nil
|
|
}
|
|
```
|
|
|
|
This lexical fallback is not a full symlink-resistant substitute for `os.Root`.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
fullPath := filepath.Join(baseDir, filename)
|
|
if !strings.HasPrefix(filepath.Clean(fullPath), filepath.Clean(baseDir)) {
|
|
return errors.New("access denied")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Zip Archive Path Traversal — High
|
|
|
|
Malicious zip files can escape extraction directory.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
for _, file := range reader.File {
|
|
path := filepath.Join(dest, file.Name) // DON'T: No validation
|
|
file.Create(path)
|
|
}
|
|
```
|
|
|
|
**Good (Go 1.24+) — use `os.Root` to scope extraction:**
|
|
|
|
```go
|
|
root, err := os.OpenRoot(dest)
|
|
if err != nil { return err }
|
|
defer root.Close()
|
|
for _, file := range reader.File {
|
|
f, err := root.OpenFile(file.Name, os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil { return err } // rejects paths escaping root
|
|
// ... copy contents ...
|
|
f.Close()
|
|
}
|
|
```
|
|
|
|
**Good (pre-Go 1.24 fallback):**
|
|
|
|
```go
|
|
for _, file := range reader.File {
|
|
if !filepath.IsLocal(file.Name) {
|
|
return fmt.Errorf("unsafe archive path: %q", file.Name)
|
|
}
|
|
|
|
targetPath, err := safeJoin(dest, file.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// create parent directories, then write targetPath
|
|
_ = targetPath
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Decompression Bomb — Medium
|
|
|
|
Tiny compressed files can expand to GBs.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
gr, _ := gzip.NewReader(f)
|
|
out, _ := os.Create(dst)
|
|
io.Copy(out, gr) // DON'T: No size limits
|
|
```
|
|
|
|
**Good:**
|
|
|
|
```go
|
|
const maxDecompressedSize = 100 * 1024 * 1024 // 100MB limit
|
|
|
|
var errDecompressedSizeLimitExceeded = errors.New("decompressed size limit exceeded")
|
|
|
|
type limitedReader struct {
|
|
r io.Reader
|
|
read int64
|
|
}
|
|
|
|
func (l *limitedReader) Read(p []byte) (int, error) {
|
|
if l.read >= maxDecompressedSize {
|
|
// Return a sentinel error — io.EOF would be treated as success by io.Copy
|
|
return 0, errDecompressedSizeLimitExceeded
|
|
}
|
|
n, err := l.r.Read(p)
|
|
l.read += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
lr := &limitedReader{r: gr}
|
|
if _, err := io.Copy(out, lr); err != nil {
|
|
return fmt.Errorf("decompressing: %w", err)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Insecure Temporary File Creation — Medium
|
|
|
|
Creating temp files without proper permissions.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
f, _ := os.Create("/tmp/myapp.temp") // DON'T: Predictable name
|
|
f.WriteString(data)
|
|
```
|
|
|
|
**Good:**
|
|
|
|
```go
|
|
f, err := os.CreateTemp("", "myapp.*")
|
|
defer os.Remove(f.Name())
|
|
f.Chmod(0600) // Restrictive permissions
|
|
```
|
|
|
|
---
|
|
|
|
## Insecure File Permissions — Medium
|
|
|
|
Opening files with excessive permissions.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
f, _ := os.OpenFile("config.json", os.O_CREATE, 0644) // DON'T: World-readable
|
|
```
|
|
|
|
**Good:**
|
|
|
|
```go
|
|
f, _ := os.OpenFile("config.json", os.O_CREATE, 0600) // OK: Owner only
|
|
```
|
|
|
|
---
|
|
|
|
## Insecure mkdir — Low
|
|
|
|
Creating directories with overly permissive permissions.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
os.MkdirAll("/var/myapp/cache", 0777) // DON'T: World-writable
|
|
```
|
|
|
|
**Good:**
|
|
|
|
```go
|
|
os.MkdirAll("/var/myapp/cache", 0750) // OK: Group-writable
|
|
```
|
|
|
|
---
|
|
|
|
## Insecure File Write Permissions — Medium
|
|
|
|
Opening files for writing with inappropriate permissions.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
os.OpenFile("app.log", os.O_CREATE, 0666) // DON'T: World-writable
|
|
```
|
|
|
|
**Good:**
|
|
|
|
```go
|
|
os.OpenFile("app.log", os.O_CREATE|os.O_APPEND, 0640) // OK
|
|
```
|
|
|
|
---
|
|
|
|
## Tainted File Read — High
|
|
|
|
Reading files based on unvalidated input.
|
|
|
|
**Bad:**
|
|
|
|
```go
|
|
func readFile(filename string) ([]byte, error) {
|
|
return os.ReadFile(filename) // DON'T: No validation
|
|
}
|
|
```
|
|
|
|
**Good (Go 1.24+):**
|
|
|
|
```go
|
|
const allowedDir = "/var/www/public/"
|
|
|
|
func readFile(filename string) ([]byte, error) {
|
|
root, err := os.OpenRoot(allowedDir)
|
|
if err != nil { return nil, err }
|
|
defer root.Close()
|
|
f, err := root.Open(filename) // cannot escape root directory
|
|
if err != nil { return nil, err }
|
|
defer f.Close()
|
|
return io.ReadAll(f)
|
|
}
|
|
```
|
|
|
|
**Good (pre-Go 1.24 fallback):**
|
|
|
|
```go
|
|
const allowedDir = "/var/www/public/"
|
|
|
|
func readFile(filename string) ([]byte, error) {
|
|
fullPath, err := safeJoin(allowedDir, filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.ReadFile(fullPath)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CWE References
|
|
|
|
- **CWE-22**: Path Traversal (Directory Traversal)
|
|
- **CWE-409**: Zip Bomb Decompression
|
|
- **CWE-379**: Insecure Temp File Creation
|
|
- **CWE-732**: Incorrect File Permissions
|