CWE-377: Insecure Temporary File - Go
Overview
Insecure temporary file vulnerabilities in Go applications occur when temporary files are created with predictable names or insecure permissions, enabling attacks like race conditions, information disclosure, or denial of service. Temporary files are commonly used for caching, processing uploaded data, storing intermediate computation results, or facilitating inter-process communication.
The primary risks include predictable filenames allowing attackers to create files before the application (winning the race condition), overly permissive file permissions exposing sensitive data to other users on shared systems, insecure temporary directories allowing symlink attacks where attackers replace temp files with symbolic links to critical system files, and temp file leaks where files aren't deleted after use, accumulating sensitive data. On Unix systems, the default /tmp directory is world-writable with the sticky bit, allowing any user to create files but preventing deletion of others' files - however, files can still be read if permissions allow.
Go's os.CreateTemp function (and the older ioutil.TempFile) provides secure defaults: an unpredictable filename, restrictive permissions (0600 on Unix: owner read/write only), and atomic exclusive creation that prevents race conditions. The name itself does not come from crypto/rand - os.CreateTemp appends a number from the runtime's general-purpose generator (runtime.rand, truncated to 32 bits) and retries on collision. The security property to rely on is the O_CREATE|O_EXCL open at mode 0600, not the entropy of the name. However, developers sometimes bypass these safe defaults by manually constructing temp file paths, using world-readable permissions, or failing to clean up temp files properly.
Primary Defence: Use os.CreateTemp for all temporary file creation - it picks an unpredictable name and creates the file exclusively at mode 0600, so no other process can be holding that path first. Always defer file cleanup with os.Remove. Never construct temp file paths manually. For sensitive data, consider encrypting temp file contents. Where a set of temp files needs its own directory, create it with os.MkdirTemp rather than os.MkdirAll.
Common Vulnerable Patterns
Predictable Temp File Names
// VULNERABLE - Predictable temporary filenames
package main
import (
"fmt"
"os"
"time"
)
func processUpload(userID int, data []byte) error {
// DANGEROUS: Predictable filename based on timestamp
timestamp := time.Now().Unix()
filename := fmt.Sprintf("/tmp/upload_%d_%d.tmp", userID, timestamp)
// Create file
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
// Write sensitive data
_, err = file.Write(data)
return err
}
// ATTACK (Race Condition):
// 1. Attacker predicts filename: /tmp/upload_1234_1707235200.tmp
// 2. Creates file with that name before application (with symlink to /etc/passwd)
// 3. Application writes to what it thinks is temp file, overwrites system file
//
// ATTACK (Information Disclosure):
// Attacker knows file pattern, reads files: /tmp/upload_*
Why this is vulnerable: Timestamps are predictable - attackers can guess filenames within the second or microsecond range. Sequential user IDs make prediction even easier. Attackers can pre-create files with known names, winning the race condition (TOCTOU: Time of Check to Time of Use). If the pre-created file is a symbolic link to a critical system file, the application overwrites that file with user data. Predictable names also allow attackers to enumerate and read the temp files themselves, exposing whatever the application put in them.
Insecure File Permissions
// VULNERABLE - World-readable temporary files
import (
"os"
)
func createTempConfig(configData []byte) (string, error) {
// Create temp file with default permissions
file, err := os.CreateTemp("", "config-*.json")
if err != nil {
return "", err
}
// DANGEROUS: Making file world-readable
os.Chmod(file.Name(), 0644) // rw-r--r--
// Write sensitive configuration (API keys, database credentials)
file.Write(configData)
file.Close()
return file.Name(), nil
}
// VULNERABILITY:
// File is readable by all users on the system
// On shared hosting, VPS, containers, other users can read temp files
// Sensitive data (credentials, tokens) exposed
Why this is vulnerable: os.CreateTemp creates files with 0600 permissions (owner read/write only) by default. Explicitly changing permissions to 0644 makes files world-readable, exposing their contents to all users on the system. On multi-tenant systems (shared hosting, containers in Kubernetes, VPS), any process or user can read whatever the file holds - credentials, session tokens, PII. Even on a single-user system, malware or any compromised process can read it.
Hard-Coded Temp Directory
// VULNERABLE - Hard-coded /tmp directory
import (
"os"
"path/filepath"
)
func saveProcessingResult(data []byte) error {
// DANGEROUS: Hard-coded /tmp on Windows might not exist
// Also ignores user preferences and security policies
tempDir := "/tmp"
// Still creates predictable path
tempFile := filepath.Join(tempDir, "result.tmp")
return os.WriteFile(tempFile, data, 0600)
}
// ISSUES:
// 1. /tmp may not exist on Windows
// 2. Ignores environment variables (TMPDIR, TEMP, TMP)
// 3. May violate security policies requiring encrypted temp storage
// 4. Fixed filename allows attackers to pre-create symlinks
Why this is vulnerable: Hard-coding /tmp breaks on Windows (which uses C:\Temp or user-specific temp directories). It ignores environment variables like TMPDIR (Unix) or TEMP/TMP (Windows) that allow users and admins to configure temp storage locations. Security policies might require temp files on encrypted volumes, which hard-coding bypasses. The fixed filename "result.tmp" allows race condition attacks - attackers pre-create it as a symlink to a target file. Using os.TempDir() respects system configuration and security policies.
Temp Files Not Deleted
// VULNERABLE - Temp file leakage
import (
"encoding/json"
"os"
)
func processData(input map[string]interface{}) error {
// Create temp file
tempFile, err := os.CreateTemp("", "data-*.json")
if err != nil {
return err
}
// Missing: defer tempFile.Close() and defer os.Remove(tempFile.Name())
// Write data
encoder := json.NewEncoder(tempFile)
if err := encoder.Encode(input); err != nil {
// DANGEROUS: Return without cleanup on error
return err
}
// Process...
readAndProcess(tempFile.Name())
// DANGEROUS: Only cleaned up on success path
tempFile.Close()
os.Remove(tempFile.Name())
return nil
}
// VULNERABILITY:
// Temp files accumulate on error paths
// Sensitive data persists on disk indefinitely
// Eventually fills /tmp, causing DoS
Why this is vulnerable: Without defer, temp files aren't deleted if errors occur before the cleanup code. Over time, abandoned temp files accumulate in /tmp, consuming disk space and potentially causing denial of service when the partition fills. More critically, sensitive data persists indefinitely - files containing credentials, PII, or business-critical data remain readable. Cleanup must be guaranteed using defer immediately after file creation, ensuring deletion even if panics or errors occur.
Symlink Attack Vulnerability
// VULNERABLE - Following symlinks in temp directory
import (
"fmt"
"os"
"path/filepath"
)
func createUserCache(userID int) error {
cacheDir := filepath.Join("/tmp", fmt.Sprintf("user_%d", userID))
// DANGEROUS: Create directory without checking if it's a symlink
if err := os.MkdirAll(cacheDir, 0700); err != nil {
return err
}
// Write cache data
cachePath := filepath.Join(cacheDir, "cache.dat")
return os.WriteFile(cachePath, []byte("cache data"), 0600)
}
// ATTACK:
// 1. Attacker creates symlink: /tmp/user_1234 -> /home/victim/.ssh
// 2. Application follows symlink, creates cache.dat in victim's .ssh directory
// 3. Attacker may overwrite authorized_keys or other critical files
Why this is vulnerable: In world-writable directories like /tmp, attackers can create symbolic links with names the application will use. When the application creates files or directories at those paths, the OS follows the symlink, creating files at the attacker-controlled target location. The application's data lands wherever the link points - the victim's .ssh directory in the example above - somewhere the attacker could not write directly. Properly using os.CreateTemp with the O_EXCL flag (set internally) prevents this by failing if a file already exists, but manually constructing paths is vulnerable.
os.MkdirAll is what makes it exploitable rather than merely predictable, and the same call appears in application code that looks nothing like a cache: os.MkdirAll(filepath.Join(os.TempDir(), "myapp-temp"), 0700) has the identical defect. MkdirAll returns nil for a path that already exists, whatever its type, mode or owner - it applies the 0700 only to directories it actually creates. So an attacker who gets there first wins twice over: with a symlink, as above, or with a real directory they own and can read, at which point every file the application writes inside it is theirs. Switching the hardcoded /tmp for os.TempDir() changes nothing on Unix, because that is where os.TempDir() points. The fixes are os.MkdirTemp, or - where the path has to be stable - an exclusive os.Mkdir plus verification, both shown below.
Secure Patterns
Using os.CreateTemp Properly
// SECURE - Proper temporary file handling
package main
import (
"fmt"
"os"
)
func processDataSecurely(data []byte) error {
// SECURE - CreateTemp picks the name and creates the file exclusively at 0600
tempFile, err := os.CreateTemp("", "secure-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
// SECURE - Guarantee cleanup even on error/panic
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Write data
if _, err := tempFile.Write(data); err != nil {
return fmt.Errorf("write failed: %w", err)
}
// Sync to ensure data is written before processing
if err := tempFile.Sync(); err != nil {
return fmt.Errorf("sync failed: %w", err)
}
// Process the temp file
if err := processTempFile(tempFile.Name()); err != nil {
return err
}
return nil
}
func processTempFile(path string) error {
// Processing logic
return nil
}
Why this works: os.CreateTemp("", "secure-*.tmp") creates a file in the system's default temp directory (respecting TMPDIR/TEMP environment variables), substituting a random number for the * to give a name such as secure-2739481103.tmp. The file is created with 0600 permissions (owner read/write only), preventing other users from reading it. The O_EXCL flag is what makes this safe rather than merely tidy - creation fails if anything already exists at that path, so an attacker cannot pre-create the file or leave a symlink there, and os.CreateTemp draws another name and retries. defer statements guarantee cleanup in all code paths (success, error, panic). Sync() ensures data is flushed to disk before processing, preventing corruption if the process crashes.
Secure Temporary Directory Creation
// SECURE - Creating temporary directories safely
import (
"fmt"
"os"
"path/filepath"
)
func createSecureTempDir() (string, func(), error) {
// SECURE - MkdirTemp creates the directory itself, at 0700
tempDir, err := os.MkdirTemp("", "processing-*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
// Return cleanup function
cleanup := func() {
os.RemoveAll(tempDir)
}
return tempDir, cleanup, nil
}
// Usage pattern
func processMultipleFiles(files [][]byte) error {
tempDir, cleanup, err := createSecureTempDir()
if err != nil {
return err
}
defer cleanup()
// Work with multiple temp files in secure directory
for i, data := range files {
filename := filepath.Join(tempDir, fmt.Sprintf("file_%d.dat", i))
if err := os.WriteFile(filename, data, 0600); err != nil {
return err
}
}
// Process files in temp directory
return processDirectory(tempDir)
}
func processDirectory(path string) error {
return nil
}
Why this works: os.MkdirTemp creates a directory with an unpredictable name and 0700 permissions (owner access only), and fails rather than reusing a path that already exists. This prevents other users from listing directory contents or accessing files within, and files created inside it are covered by the directory's restrictions as well as by their own mode. Returning a cleanup function gives the caller one thing to defer, and os.RemoveAll takes the directory and everything in it. The pattern suits work that spreads across several related temp files.
Specifying Custom Temp Directory
// SECURE - Application-specific temp directory, created fresh for each run
import (
"fmt"
"os"
)
func createTempInSecureLocation() (*os.File, string, error) {
// SECURE - MkdirTemp creates a new 0700 directory under os.TempDir() and
// fails if that path is taken, so the application never adopts a
// directory some other local account created first.
appTempDir, err := os.MkdirTemp("", "myapp-")
if err != nil {
return nil, "", fmt.Errorf("create app temp dir: %w", err)
}
tempFile, err := os.CreateTemp(appTempDir, "data-*.tmp")
if err != nil {
os.RemoveAll(appTempDir)
return nil, "", fmt.Errorf("create temp file: %w", err)
}
// Caller removes appTempDir when finished, which takes the file with it.
return tempFile, appTempDir, nil
}
Why this works: Grouping an application's temp files under one directory is worth doing - it gives you a single path to clean up and keeps unrelated files out of the way - but the directory has to be created, not merely ensured, which is the distinction os.MkdirAll loses (see Symlink Attack Vulnerability above). os.MkdirTemp makes a new directory with a name nobody could have predicted, at mode 0700, and returns an error rather than reusing an existing path, so there is nothing an attacker can have prepared for it to adopt. Files created inside it with os.CreateTemp are then protected by the directory as well as by their own 0600 mode.
Reusing a Temp Directory Between Runs
Where a stable path really is required - a cache that has to survive process restarts, say - put it somewhere only the user can write: $XDG_RUNTIME_DIR, the user's home directory, or a service-owned directory such as /var/lib/myapp. If it has to sit under the shared temp root, create it exclusively and check what you got:
// SECURE - Stable directory under a shared root, verified before use
//go:build unix
package appdir
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
func openAppDir(base, name string) (string, error) {
dir := filepath.Join(base, name)
// os.Mkdir reports ErrExist rather than adopting an existing path.
err := os.Mkdir(dir, 0o700)
if err == nil {
return dir, nil
}
if !os.IsExist(err) {
return "", err
}
// It was already there, so it is usable only if it is a real directory,
// owned by this user, with no access for anyone else. Lstat rather than
// Stat, so a symlink is reported as a symlink instead of followed.
fi, err := os.Lstat(dir)
if err != nil {
return "", err
}
if !fi.Mode().IsDir() {
return "", fmt.Errorf("%s exists and is not a directory", dir)
}
if perm := fi.Mode().Perm(); perm&0o077 != 0 {
return "", fmt.Errorf("%s is open to other users (mode %#o)", dir, perm)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return "", fmt.Errorf("cannot read ownership of %s", dir)
}
if int(st.Uid) != os.Getuid() {
return "", fmt.Errorf("%s is owned by uid %d, not %d", dir, st.Uid, os.Getuid())
}
return dir, nil
}
Why this works: os.Mkdir is the atomic half - it either creates the directory at mode 0700 or reports ErrExist, with no window in which the directory exists with wider permissions. The checks cover the case it cannot: a directory left behind by an earlier run looks identical to one an attacker planted, so type, mode and ownership all have to be confirmed before anything is written into it, and refusing to continue is the right outcome when they do not match. The ownership check is Unix-specific, which is why the file carries a build tag - on Windows os.TempDir() is already per-user, so this is a Unix problem.
Encrypting Sensitive Temp Files
// SECURE - encryption as defence in depth on top of a correct temp file
// lifecycle, not as a substitute for one
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
"log"
"os"
)
// createEncryptedTempFile returns the path and the cleanup function that owns
// it. The caller must call cleanup - normally with defer immediately after the
// error check - on the success path as well as on failure.
func createEncryptedTempFile(key, plaintext []byte) (string, func(), error) {
tempFile, err := os.CreateTemp("", "encrypted-*.tmp")
if err != nil {
return "", nil, err
}
tempPath := tempFile.Name()
cleanup := func() {
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
log.Printf("warning: failed to remove temp file %s: %v", tempPath, err)
}
}
// Encrypt before anything reaches the disk
ciphertext, err := encryptData(key, plaintext)
if err != nil {
tempFile.Close()
cleanup()
return "", nil, err
}
if _, err := tempFile.Write(ciphertext); err != nil {
tempFile.Close()
cleanup()
return "", nil, err
}
// Close before returning: on Windows os.Remove fails while a handle is open,
// so a caller's deferred cleanup would silently leave the file behind
if err := tempFile.Close(); err != nil {
cleanup()
return "", nil, fmt.Errorf("closing temp file: %w", err)
}
return tempPath, cleanup, nil
}
func useEncryptedTempFile(key, plaintext []byte) error {
path, cleanup, err := createEncryptedTempFile(key, plaintext)
if err != nil {
return err
}
defer cleanup()
decrypted, err := readAndDecryptTempFile(path, key)
if err != nil {
return fmt.Errorf("reading temp file: %w", err)
}
return processPlaintext(decrypted)
}
func encryptData(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// Seal prepends the nonce, so it travels with the ciphertext
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func readAndDecryptTempFile(path string, key []byte) ([]byte, error) {
ciphertext, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return decryptData(key, ciphertext)
}
func decryptData(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}
Why this works: os.CreateTemp() is what fixes CWE-377 here - unpredictable name, exclusive creation, 0600. The encryption is defence in depth layered on top of that, for the cases the file mode does not cover: a backup that sweeps up /tmp, a snapshot of the volume, or forensic recovery of the blocks after deletion. AES-256-GCM gives confidentiality and integrity, and gcm.Seal(nonce, nonce, ...) prepends the nonce so it is stored with the ciphertext rather than lost - decryptData splits it back off, which is why a round-trip test is the check that matters here.
Encryption does not remediate the lifecycle half of CWE-377, which is why the cleanup contract is part of the signature. A function that returns a path and nothing else leaves its lifetime unowned: the caller has to know to delete it, and encrypted data left on disk indefinitely is still data left on disk. Returning cleanup alongside the path makes defer cleanup() the obvious thing to write at the call site. The key is the other half - hold it in memory, or in a key vault or KMS, and never beside the file it protects.
Safe Cleanup with Error Handling
// SECURE - Robust cleanup with error handling
import (
"fmt"
"log"
"os"
)
func processWithRobustCleanup(data []byte) error {
tempFile, err := os.CreateTemp("", "process-*.tmp")
if err != nil {
return err
}
tempPath := tempFile.Name()
// SECURE - Cleanup that logs errors but doesn't block
defer func() {
if err := tempFile.Close(); err != nil {
log.Printf("Warning: failed to close temp file %s: %v", tempPath, err)
}
if err := os.Remove(tempPath); err != nil {
log.Printf("Warning: failed to remove temp file %s: %v", tempPath, err)
}
}()
// Write and process
if _, err := tempFile.Write(data); err != nil {
return fmt.Errorf("write failed: %w", err)
}
if err := processTempFile(tempPath); err != nil {
return fmt.Errorf("processing failed: %w", err)
}
return nil
}
Why this works: Cleanup errors (file already deleted, permission denied) shouldn't cause the main function to fail, but should be logged for debugging. The defer func() closure allows handling cleanup errors independently - logging warnings without returning errors. This prevents cleanup failures from masking the original error. Close the file before removing it: os.Remove works on an open file on Unix, but on Windows it fails while a handle is still open.
Testing with Temp Files
// SECURE - Using temp files in tests
package main_test
import (
"os"
"testing"
)
func TestDataProcessing(t *testing.T) {
// SECURE - Create temp file for test
tempFile, err := os.CreateTemp("", "test-*.dat")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
// SECURE - Clean up after test
t.Cleanup(func() {
tempFile.Close()
os.Remove(tempFile.Name())
})
// Write test data
testData := []byte("test content")
if _, err := tempFile.Write(testData); err != nil {
t.Fatalf("Write failed: %v", err)
}
tempFile.Sync()
// Test function that processes the file
result, err := processFile(tempFile.Name())
if err != nil {
t.Errorf("Processing failed: %v", err)
}
// Assertions
if result != "expected" {
t.Errorf("Got %s, want expected", result)
}
}
func processFile(path string) (string, error) {
return "expected", nil
}
Why this works: t.Cleanup() registers cleanup functions that run after the test completes (even if the test fails or panics), similar to defer but test-aware, so temp files created during a test are removed whatever the test does. Using os.CreateTemp in tests provides the same security benefits as production code, and tests running in parallel can create temp files without naming conflicts (the names are unpredictable).
Common Pitfalls
- Manually joining a filename onto
os.TempDir()instead of usingos.CreateTemp:filepath.Join(os.TempDir(), "data.tmp")uses the correct base directory but reverts to a fixed, predictable name -os.TempDir()only answers "where," it does no randomization; onlyos.CreateTemp/os.MkdirTempgenerate the unpredictable name and create the file atomically. - Setting permissions in a separate
os.Chmod()call after creation:os.WriteFile(path, data, 0600)applies the mode atomically at creation, but code that instead callsos.Create()(0666 modified by umask) followed by a separateos.Chmod(path, 0600)leaves a brief window where the file exists with broader permissions before the chmod call lands. - Deriving a second path from a securely-created file by string manipulation: Calling
os.CreateTemp("", "x-*.tmp")to get a securely-created file, then computing a "matching" lock or log file path by swapping the extension instead of creating that second file the same secure way. The first file is safe; the derived one silently reverts to a predictable name.
Additional Resources
- Go os Package - CreateTemp
- Go os Package - MkdirTemp
- Go source: os/tempfile.go - the name comes from
runtime.rand, and the file from anO_EXCLopen at 0600 - CWE-377: Insecure Temporary File
- CWE-379: Creation of Temporary File in Directory with Insecure Permissions
- OWASP File Upload Cheat Sheet
- Securing Temporary Files (CERT)