Skip to content

nightshift: security-footgun #39

Description

@nightshift-micr

Security Footgun Analysis: Microck/tailstick

Repository: ~/nightshift-workspace/clones/tailstick
Language: Go
Date: 2026-04-23
Analyzer: Automated security-footgun audit


Summary

The tailstick project is a Tailscale device lease management tool that handles enrollment, cleanup, and lifecycle management of Tailscale nodes. It processes sensitive credentials (API keys, auth keys, operator passwords) and exposes an HTTP GUI for enrollment. The analysis identified 9 security concerns across the codebase, ranging from high to low severity.

Severity Count
High 3
Medium 4
Low 2

Findings

1. [HIGH] GUI HTTP Server Has No Authentication or CSRF Protection

Files:

  • internal/gui/server.go (lines 65–106, 132–184)
  • internal/app/gui.go (lines 39–46)

Description:
The GUI HTTP server binds to a user-configurable host/port (defaulting to 127.0.0.1) and exposes two sensitive endpoints — /api/presets and /api/enroll — with zero authentication. Any process on the local machine (or reachable host if the operator changes --host) can:

  1. Read preset configurations via GET /api/presets (including approved exit nodes, tags, etc.)
  2. Trigger a full Tailscale enrollment via POST /api/enroll, which installs software, runs tailscale up with credentials, and creates system-level services

There is no CSRF token, no session management, no API key check, and no token-based authentication on these endpoints. The only gate is the optional operatorPassword which is checked inside EnrollFn — meaning the password flows through the unauthenticated HTTP handler.

Code Snippet (internal/gui/server.go:132–176):

func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    var req enrollRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid json body", http.StatusBadRequest)
        return
    }
    // ... validation of mode/channel/days ...
    rec, err := s.EnrollFn(r.Context(), model.RuntimeOptions{
        PresetID:      req.PresetID,
        // ... all fields passed directly from HTTP body ...
        Password:      password,
    })
    // ...
}

Remediation:

  • Add a session-based or token-based authentication layer to all GUI endpoints
  • Implement CSRF protection (e.g., double-submit cookie pattern or SameSite cookies)
  • Consider binding exclusively to 127.0.0.1 with a random ephemeral token in the URL path
  • Add rate limiting on the enroll endpoint

2. [HIGH] Config File Allows Arbitrary Command Execution via Install/Uninstall Arrays

Files:

  • internal/model/types.go (lines 55–62)
  • internal/tailscale/client.go (lines 198–228)
  • internal/config/config.go (full file)
  • configs/tailstick.config.example.json (lines 16–22)

Description:
The config file's install block accepts arbitrary string arrays for linuxStable, linuxLatest, linuxUninstall, windowsStable, windowsLatest, windowsUninstall. These arrays are passed directly to exec.CommandContext via platform.Runner.Run(). The config file is JSON loaded from disk with no integrity verification.

Anyone who can write to the config file (tailstick.config.json) can inject arbitrary commands that will be executed with root/Administrator privileges (enrollment requires elevation). The example config even demonstrates shell-piped installs:

"linuxStable": ["bash", "-lc", "curl -fsSL https://tailscale.com/install.sh | sh"]

Code Snippet (internal/tailscale/client.go:198–214):

func installCommand(preset model.Preset, channel model.Channel) []string {
    if runtime.GOOS == "windows" {
        // ...
        return preset.Install.WindowsLatest  // arbitrary command from config
    }
    if channel == model.ChannelLatest && len(preset.Install.LinuxLatest) > 0 {
        return preset.Install.LinuxLatest  // arbitrary command from config
    }
    // ...
}

Code Snippet (internal/platform/exec.go:19–34):

func (r Runner) Run(ctx context.Context, args []string) (string, error) {
    // ...
    cmd := exec.CommandContext(ctx, args[0], args[1:]...)
    // ... executed directly
}

Remediation:

  • Validate config install/uninstall commands against an allowlist of known-safe binaries
  • Restrict install commands to specific, vetted patterns (e.g., only tailscale, apt-get, winget, powershell with specific subcommands)
  • Sign the config file and verify the signature before loading
  • At minimum, document that the config file must be writeable only by privileged users

3. [HIGH] os.ExpandEnv on Config File Enables Environment Variable Injection

File: internal/config/config.go (line 31)

Description:
The config loader applies os.ExpandEnv() to the entire raw config file before JSON parsing. This means any environment variable on the host system can be interpolated into the config. An attacker who can control an environment variable (e.g., via a parent process, systemd unit, or .env file) can inject values into the configuration, including into the arbitrary command arrays described in Finding #2.

Code Snippet (internal/config/config.go:30–34):

b, err := os.ReadFile(path)
if err != nil {
    return model.Config{}, fmt.Errorf("read config: %w", err)
}
// Allow ${ENV_VAR} expansion in config values.
expanded := os.ExpandEnv(string(b))

var cfg model.Config
if err := json.Unmarshal([]byte(expanded), &cfg); err != nil {

Remediation:

  • Remove os.ExpandEnv or restrict it to specific known-safe fields (like password/key references)
  • Use a custom expander that only allows a whitelist of TAILSTICK_* environment variables
  • Consider removing env expansion entirely since authKeyEnv/apiKeyEnv/operatorPasswordEnv already handle secret resolution

4. [MEDIUM] Error Messages Leak Internal Details to HTTP Clients

Files:

  • internal/gui/server.go (lines 113–116, 174)

Description:
Internal errors are returned directly to HTTP clients, potentially leaking file paths, system configuration details, and operational internals.

Code Snippet (internal/gui/server.go:113–116):

func (s *Server) presets(w http.ResponseWriter, r *http.Request) {
    cfg, err := config.Load(s.ConfigPath)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        // Leaks: "read config /path/to/tailstick.config.json: no such file or directory"

Code Snippet (internal/gui/server.go:174):

if err != nil {
    writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
    // Leaks full internal error including tailscale CLI output, file paths, etc.
}

Remediation:

  • Log the full error server-side
  • Return a generic error message to the HTTP client with a correlation ID
  • Sanitize error messages before sending to clients

5. [MEDIUM] Race Condition in State File Load-Modify-Save Pattern

Files:

  • internal/app/workflow.go (lines 184–191, 267–270, 287–302)
  • internal/state/store.go (lines 37–53)

Description:
The AgentOnce, AgentRun, and ForceCleanup methods follow a load-modify-save pattern without file locking. If multiple instances of the agent run concurrently (e.g., a timer fires while a manual cleanup command is in progress), the last writer wins and can silently overwrite the other's changes.

Code Snippet (internal/app/workflow.go:283–302):

func (m *Manager) ForceCleanup(ctx context.Context, leaseID string) error {
    // ...
    st, err := state.Load(m.Runtime.StatePath)    // READ
    // ... modify st in memory ...
    return state.Save(m.Runtime.StatePath, st)     // WRITE — no lock!
}

The state.Save function uses atomic rename (tmp + rename) which prevents file corruption, but does not prevent logical races where concurrent load-modify-save cycles lose updates.

Remediation:

  • Use flock (file locking) or a mutex backed by a lock file to ensure exclusive access to the state file
  • Alternatively, use a single-process model with an in-memory lock

6. [MEDIUM] Temp File Race Window for Auth Key Before chmod

File: internal/tailscale/client.go (lines 172–196)

Description:
The authKeyArg function creates a temp file, writes the auth key, then sets permissions to 0600. Between os.CreateTemp (which creates the file with default 0666 minus umask, typically 0644 on Linux) and the f.Chmod(0o600) call, there is a window where the file is world-readable and contains the Tailscale auth key. On a multi-user system, another user could read the key during this window.

Code Snippet (internal/tailscale/client.go:172–190):

func authKeyArg(auth string) (string, func(), error) {
    f, err := os.CreateTemp("", "tailstick-auth-key-*")
    // File is now on disk with default permissions (world-readable before umask)
    // ...
    if _, err := f.WriteString(auth); err != nil {  // Secret written
        // ...
    }
    if err := f.Chmod(0o600); err != nil && runtime.GOOS != "windows" {
        // Permission set AFTER secret is already on disk

Remediation:

  • Use os.OpenFile with O_CREATE|O_EXCL|O_WRONLY and mode 0600 directly instead of os.CreateTemp
  • Alternatively, call f.Chmod(0o600) before writing the secret content

7. [MEDIUM] Machine-Context-Based Encryption Has No Key Rotation or Binding Verification

File: internal/crypto/secret.go (lines 98–128)

Description:
When no password is provided, the encryption key for sensitive credentials (API keys, cleanup secrets) is derived from machine context (/etc/machine-id, hostname, OS, arch). This means:

  1. Any process on the same machine can decrypt the secrets if it can read the encrypted blob (e.g., from the state file or credential files in /var/lib/tailstick/secrets/)
  2. The encryption is deterministic per machine — there is no per-secret key material when using "machine" mode
  3. The EncryptedSecret is stored in the state file (state.json) with 0o644-readable parent directories, making it accessible to all local users

Code Snippet (internal/crypto/secret.go:114–128):

func deriveKeyWithSalt(password, machineContext string, salt []byte, mode string) ([]byte, []byte, string, error) {
    base := password
    if mode == "machine" {
        base = machineContext  // Machine-context used as key material
    }
    // ...
    combined := sha256.Sum256([]byte(base))
    key, err := scrypt.Key(combined[:], salt, 1<<15, 8, 1, 32)

Remediation:

  • Prefer password-based encryption with a strong passphrase
  • If machine-context encryption is required, store the encrypted secrets with 0o600 parent directory permissions (already partially done for /var/lib/tailstick/secrets/ at 0o700, but the state file at /var/lib/tailstick/state.json has 0o755 parent)
  • Document that machine-context encryption is only as strong as local access controls

8. [LOW] Password Compared with Constant-Time Compare but Stored in Plaintext in Config

Files:

  • internal/app/workflow.go (lines 104–108)
  • internal/config/config.go (lines 37–39)

Description:
The operator password is compared using subtle.ConstantTimeCompare (good practice), but the password itself is stored in plaintext either directly in the config file (operatorPassword field) or resolved from an environment variable. The config file has no built-in protection — it relies on filesystem permissions.

Code Snippet (internal/app/workflow.go:104–108):

if expected := strings.TrimSpace(cfg.OperatorPassword); expected != "" {
    if subtle.ConstantTimeCompare([]byte(opts.Password), []byte(expected)) != 1 {
        return model.LeaseRecord{}, fmt.Errorf("operator password is invalid")
    }
}

Remediation:

  • Store the operator password as a bcrypt/argon2 hash instead of plaintext
  • Add a operatorPasswordHash field to the config schema
  • Document that operatorPasswordEnv is preferred over inline operatorPassword

9. [LOW] JSON Decoder Does Not Limit Request Body Size

File: internal/gui/server.go (lines 137–141)

Description:
The /api/enroll endpoint decodes JSON directly from r.Body without setting a size limit. A malicious client could send an extremely large JSON payload, causing excessive memory consumption on the server.

Code Snippet (internal/gui/server.go:137–141):

var req enrollRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    http.Error(w, "invalid json body", http.StatusBadRequest)
    return
}

Remediation:

  • Wrap r.Body with io.LimitReader(r.Body, maxBodySize) before decoding
  • Example: json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req)

Items Reviewed — No Issues Found

Category Result
SQL injection ✅ No SQL/database usage detected
Hardcoded secrets in source ✅ No hardcoded API keys, tokens, or passwords in Go source files (.env.example uses placeholder values)
Insecure TLS configurations ✅ TLS is not directly configured; HTTP client uses default (secure) settings
Unsafe deserialization ✅ JSON unmarshaling into typed structs; no interface{} abuse
Path traversal ✅ File paths are constructed from trusted config/internal values, not user input
Command injection via string concatenation ✅ Commands use exec.Command with argument arrays (no shell expansion)
File permissions ✅ Generally good: 0o600 for secrets, 0o700 for secret directories. Minor issue noted in Finding #6

Positive Security Practices Observed

  1. Auth key file-based passing (--auth-key=file:) — avoids leaking secrets in process argument lists
  2. Constant-time password comparison — prevents timing side-channels
  3. Atomic file writes — state file uses temp+rename pattern
  4. Input validation — mode, channel, days, exit node allowlist all validated before use
  5. Credential cleanup — encrypted credential files are deleted after use
  6. AES-GCM with scrypt — strong authenticated encryption with proper KDF
  7. Preset summaries redact secrets/api/presets endpoint does not expose auth keys or API keys
  8. Default localhost binding — GUI binds to 127.0.0.1 by default

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions