security: fix env race, signing key, upload traversal, password hash (#106)

* security: fix four audit findings -- env race, signing key, upload traversal, password hash

1. Race condition in os.environ (HIGH): Per-session _agent_lock didn't
   prevent cross-session env writes from interleaving. Added global
   _ENV_LOCK in streaming.py that serializes the entire env save/restore
   block across all sessions.

2. Predictable signing key (MEDIUM): sha256(STATE_DIR) was deterministic.
   Now generates a random 32-byte key on first startup and persists it to
   STATE_DIR/.signing_key (chmod 600). Existing sessions invalidated on
   first restart (acceptable for a security fix).

3. Upload path traversal (MEDIUM): Filename '..' survived the regex
   sanitization (dots are allowed chars). Added explicit rejection of
   dot-only names and safe_resolve_ws() check to verify the resolved
   path stays within the workspace.

4. Weak password hashing (MEDIUM): Replaced bare SHA-256 with PBKDF2-
   SHA256 (600k iterations per OWASP). Uses stdlib hashlib.pbkdf2_hmac,
   no new dependencies. Note: existing passwords must be re-set after
   this change (hash format changed).

Closes #106

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use random signing key as PBKDF2 salt (replaces predictable STATE_DIR salt)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-04 22:25:08 -07:00
committed by GitHub
parent 853b23cd14
commit 39066bc614
3 changed files with 38 additions and 7 deletions

View File

@@ -26,14 +26,35 @@ _sessions = {}
def _signing_key():
"""Derive a stable signing key from STATE_DIR."""
return hashlib.sha256(str(STATE_DIR).encode()).digest()
"""Return a random signing key, generating and persisting one on first call."""
key_file = STATE_DIR / '.signing_key'
if key_file.exists():
try:
raw = key_file.read_bytes()
if len(raw) >= 32:
return raw[:32]
except Exception:
pass
# Generate a new random key
key = secrets.token_bytes(32)
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
key_file.write_bytes(key)
key_file.chmod(0o600)
except Exception:
pass # key works for this process even if persist fails
return key
def _hash_password(password):
"""SHA-256 hash with a salt derived from STATE_DIR."""
salt = str(STATE_DIR).encode()
return hashlib.sha256(salt + password.encode()).hexdigest()
"""PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
Salt is the persisted random signing key, which is secret and unique per
installation. This keeps the stored hash format a plain hex string
(no format change to settings.json) while replacing the predictable
STATE_DIR-derived salt from the original implementation."""
salt = _signing_key()
dk = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 600_000)
return dk.hex()
def get_password_hash():