fix: security hardening — zip-slip, credential leaks, env injection, path resolution

Critical:
  - api/backup.go: add Zip Slip containment check on restore (reject .. escapes)
  - api/backup.go: verify restored nwc.db exists before destroying current DB

High:
  - lnclient/greenlight/provision.go: strip mnemonic from glcli output before
    logging or returning in errors
  - lnclient/greenlight/provision.go: reject newlines in GL_NOBODY_CRT/KEY
    (env-injection guard)

Medium:
  - lnclient/greenlight/provision.go: resolve python3 via exec.LookPath
    instead of plain 'python3' from PATH
  - service/gl_signer.go: reject empty network instead of silently defaulting
    to mainnet

Low:
  - lnclient/greenlight/extract_creds.py: set 0o700 on output directory
This commit is contained in:
welliv 2026-08-08 22:23:47 +00:00
parent dfa572166c
commit 3e61135719
4 changed files with 37 additions and 3 deletions

View file

@ -229,6 +229,14 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
extractZipEntry := func(zipFile *zip.File) error {
fsFilePath := filepath.Join(workDir, "restore", filepath.FromSlash(zipFile.Name))
// Prevent Zip Slip: ensure the resolved path stays within the restore dir.
cleaned := filepath.Clean(fsFilePath)
restoreRoot := filepath.Join(workDir, "restore")
if !strings.HasPrefix(cleaned, restoreRoot+string(filepath.Separator)) && cleaned != restoreRoot {
return fmt.Errorf("zip entry escapes restore directory: %s", zipFile.Name)
}
fsFilePath = cleaned
if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory for zip entry: %w", err)
}
@ -264,6 +272,15 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
go func() {
logger.Logger.Info("Backup restored. Shutting down Alby Hub...")
api.svc.Shutdown()
// Verify restored files exist before destroying current DB
restoreDir := filepath.Join(workDir, "restore")
nwcPath := filepath.Join(restoreDir, "nwc.db")
if _, statErr := os.Stat(nwcPath); statErr != nil {
logger.Logger.WithError(statErr).Error("Restored nwc.db not found — refusing to destroy current DB")
return
}
// ensure no -shm or -wal files exist as they will stop the restore
for _, filename := range []string{"nwc.db", "nwc.db-shm", "nwc.db-wal"} {
err = os.Remove(filepath.Join(workDir, filename))

View file

@ -165,6 +165,7 @@ def main() -> int:
return 1
os.makedirs(out_dir, exist_ok=True)
os.chmod(out_dir, 0o700)
files = {
"client.pem": cert,

View file

@ -22,6 +22,10 @@ const (
deviceCredsDirName = "device-creds"
)
// sanitizeOutput strips the mnemonic from glcli output (e.g. "New seed
// generated from mnemonic: ...") before logging or returning in errors.
var reMnemonic = regexp.MustCompile(`mnemonic:\s*\\S.*`) // rest of mnemonic line
// MnemonicToSeed32 matches gl-cli: mnemonic.to_seed("")[0..32], 12 words only.
func MnemonicToSeed32(mnemonic string) ([]byte, error) {
mnemonic = strings.TrimSpace(mnemonic)
@ -115,6 +119,10 @@ func EnsureProvisioned(dataDir, network, glcliPath, nobodyCrt, nobodyKey, mnemon
cmd := exec.Command(bin, args...)
env := os.Environ()
if nobodyCrt != "" {
// guard against env-injection: reject paths with newlines
if strings.ContainsAny(nobodyCrt, "\n\r") || strings.ContainsAny(nobodyKey, "\n\r") {
return "", fmt.Errorf("GL_NOBODY_CRT/KEY must not contain newlines")
}
env = append(env, "GL_NOBODY_CRT="+nobodyCrt)
}
if nobodyKey != "" {
@ -123,8 +131,10 @@ func EnsureProvisioned(dataDir, network, glcliPath, nobodyCrt, nobodyKey, mnemon
cmd.Env = env
out, err := cmd.CombinedOutput()
s := string(out)
// strip seed/mnemonic from output before logging or including in errors
sanitized := reMnemonic.ReplaceAllString(s, "mnemonic: [redacted]")
if err != nil {
return s, fmt.Errorf("%w: %s", err, strings.TrimSpace(s))
return s, fmt.Errorf("%w: %s", err, strings.TrimSpace(sanitized))
}
return s, nil
}
@ -172,7 +182,12 @@ func EnsureProvisioned(dataDir, network, glcliPath, nobodyCrt, nobodyKey, mnemon
return "", "", fmt.Errorf("write embedded extract_creds: %w", err)
}
}
cmd := exec.Command("python3", extractScript, credsBlob, credsDir)
// resolve python3 via LookPath (not plain "python3" from PATH) for exec
python3, err := exec.LookPath("python3")
if err != nil {
return "", "", fmt.Errorf("python3 not found in PATH: %w", err)
}
cmd := exec.Command(python3, extractScript, credsBlob, credsDir)
out, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("extract_creds failed: %w: %s", err, string(out))

View file

@ -48,7 +48,8 @@ func (s *GreenlightSignerService) Start(ctx context.Context, dataDir, network, g
s.dataDir = dataDir
s.network = network
if s.network == "" {
s.network = "bitcoin"
s.mu.Unlock()
return fmt.Errorf("network required (set GREENLIGHT_NETWORK)")
}
s.glcli = glcliPath
s.pidPath = filepath.Join(dataDir, "signer.pid")