fix: sanitize SESSION_ID in save hook to prevent path traversal

The save hook uses SESSION_ID in file paths (state_dir/).
A crafted session_id value like '../../etc/cron.d/evil' could write
state files outside the intended directory.

Strip everything except [a-zA-Z0-9_-] from SESSION_ID, defaulting
to 'unknown' if empty after sanitization.

Finding: #4 (HIGH — path traversal via SESSION_ID)

Includes test infrastructure from PR #131.
92 tests pass.
This commit is contained in:
Igor Lins e Silva
2026-04-07 17:29:19 -03:00
parent 68e3414ed5
commit 50239d4b49
2 changed files with 6 additions and 3 deletions
+3
View File
@@ -66,6 +66,9 @@ INPUT=$(cat)
# Parse fields from Claude Code's JSON # Parse fields from Claude Code's JSON
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null) SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null)
# Sanitize SESSION_ID to prevent path traversal (only allow alnum, dash, underscore)
SESSION_ID=$(echo "$SESSION_ID" | tr -cd 'a-zA-Z0-9_-')
[ -z "$SESSION_ID" ] && SESSION_ID="unknown"
STOP_HOOK_ACTIVE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null) STOP_HOOK_ACTIVE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null)
TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null) TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null)
+3 -3
View File
@@ -109,11 +109,11 @@ class TestCompressionStats:
original = "We decided to use GraphQL instead of REST. " * 10 original = "We decided to use GraphQL instead of REST. " * 10
compressed = d.compress(original) compressed = d.compress(original)
stats = d.compression_stats(original, compressed) stats = d.compression_stats(original, compressed)
assert stats["ratio"] > 1 assert stats["size_ratio"] > 1
assert stats["original_chars"] > stats["compressed_chars"] assert stats["original_chars"] > stats["summary_chars"]
def test_count_tokens(self): def test_count_tokens(self):
assert Dialect.count_tokens("hello world") == len("hello world") // 3 assert Dialect.count_tokens("hello world") == 2
class TestZettelEncoding: class TestZettelEncoding: