Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef34d938bf |
+62
-4
@@ -22,8 +22,62 @@ import gitea_config
|
|||||||
|
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# Load standard .env if present
|
# Reserved runtime-control / workspace-binding environment variables (#704).
|
||||||
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
|
# Repository .env files MUST NOT populate or override any of these keys.
|
||||||
|
RESERVED_WORKTREE_ENV_KEYS: frozenset[str] = frozenset({
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_RECONCILER_WORKTREE",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def is_reserved_worktree_env_key(key: str | None) -> bool:
|
||||||
|
"""Return True if *key* is a reserved runtime workspace binding variable (#704)."""
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
k = str(key).upper().strip()
|
||||||
|
return k in RESERVED_WORKTREE_ENV_KEYS or (k.startswith("GITEA_") and k.endswith("_WORKTREE"))
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file_sanitized(
|
||||||
|
env_path: str,
|
||||||
|
*,
|
||||||
|
target_env: dict | os._Environ | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Load a .env file without populating or overriding reserved workspace keys (#704).
|
||||||
|
|
||||||
|
Pre-existing process environment values retain their precedence. Reserved
|
||||||
|
runtime-control keys found in repository files are ignored (without logging
|
||||||
|
their values). Returns a list of sanitized rejection reasons.
|
||||||
|
"""
|
||||||
|
if target_env is None:
|
||||||
|
target_env = os.environ
|
||||||
|
if not os.path.exists(env_path) or os.path.isdir(env_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
rejection_reasons: list[str] = []
|
||||||
|
try:
|
||||||
|
file_vals = dotenv_values(env_path)
|
||||||
|
for key, val in file_vals.items():
|
||||||
|
if not key or val is None:
|
||||||
|
continue
|
||||||
|
if is_reserved_worktree_env_key(key):
|
||||||
|
filename = os.path.basename(env_path)
|
||||||
|
rejection_reasons.append(
|
||||||
|
f"Ignored reserved runtime workspace key '{key}' from repository {filename}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if key not in target_env:
|
||||||
|
target_env[key] = val
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return rejection_reasons
|
||||||
|
|
||||||
|
|
||||||
|
# Load standard .env if present (sanitized to prevent repo workspace binding contamination #704)
|
||||||
|
load_env_file_sanitized(os.path.join(PROJECT_ROOT, ".env"))
|
||||||
|
|
||||||
# Dictionary to store configurations parsed dynamically from .env.* files
|
# Dictionary to store configurations parsed dynamically from .env.* files
|
||||||
DYNAMIC_CONFIGS = {}
|
DYNAMIC_CONFIGS = {}
|
||||||
@@ -37,9 +91,13 @@ for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
config_vals = dotenv_values(env_path)
|
config_vals = dotenv_values(env_path)
|
||||||
site = config_vals.get("GITEA_SITE") or config_vals.get("GITEA_HOST")
|
# Filter out reserved workspace keys from dynamic configs (#704)
|
||||||
|
sanitized_config = {
|
||||||
|
k: v for k, v in config_vals.items() if not is_reserved_worktree_env_key(k)
|
||||||
|
}
|
||||||
|
site = sanitized_config.get("GITEA_SITE") or sanitized_config.get("GITEA_HOST")
|
||||||
if site:
|
if site:
|
||||||
DYNAMIC_CONFIGS[site.lower().strip()] = config_vals
|
DYNAMIC_CONFIGS[site.lower().strip()] = sanitized_config
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Tests for Issue #704: Preventing repository .env files from injecting workspace bindings.
|
||||||
|
|
||||||
|
Acceptance Criteria (#704):
|
||||||
|
1. Repository .env loading cannot populate or override GITEA_ACTIVE_WORKTREE or any role-specific GITEA_*_WORKTREE runtime-binding variable.
|
||||||
|
2. Runtime workspace bindings are accepted only from sanctioned managed-launch/session mechanisms.
|
||||||
|
3. Pre-existing sanctioned process environment values retain their intended precedence.
|
||||||
|
4. Importing gitea_auth or related modules does not mutate workspace-binding state from repository files.
|
||||||
|
5. Reserved runtime-control keys found in .env are ignored or rejected with a sanitized actionable reason; their values are never logged.
|
||||||
|
6. The protection applies consistently to author, reviewer, merger, and reconciler namespaces.
|
||||||
|
7. Comprehensive test coverage for stale worktree, missing worktree, task-specific, role-specific, launcher binding, repeated imports, namespace isolation, precedence, and absence of secret leakage.
|
||||||
|
8. Dirty-state and workspace-preflight gates cannot be bypassed by an injected missing-path binding.
|
||||||
|
9. No environment, dotenv, offline-import, or caller-controlled path can forge native transport or mutation provenance.
|
||||||
|
10. Cross-linked with #702, PR #703, #510.
|
||||||
|
11. Required immediate follow-up to Issue #702 / PR #703.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import importlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_auth
|
||||||
|
from gitea_auth import is_reserved_worktree_env_key, load_env_file_sanitized
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue704PreventEnvWorkspaceBindings(unittest.TestCase):
|
||||||
|
"""Test suite verifying .env workspace-binding injection prevention (#704)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmpdir.cleanup)
|
||||||
|
self.env_dir = Path(self.tmpdir.name)
|
||||||
|
|
||||||
|
def test_is_reserved_worktree_env_key(self):
|
||||||
|
"""Verify key classification for all role namespaces (#704 AC6)."""
|
||||||
|
reserved_keys = [
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_RECONCILER_WORKTREE",
|
||||||
|
"gitea_active_worktree",
|
||||||
|
"GITEA_CUSTOM_ROLE_WORKTREE",
|
||||||
|
]
|
||||||
|
for key in reserved_keys:
|
||||||
|
self.assertTrue(
|
||||||
|
is_reserved_worktree_env_key(key),
|
||||||
|
f"Expected {key} to be recognized as a reserved worktree key",
|
||||||
|
)
|
||||||
|
|
||||||
|
unreserved_keys = [
|
||||||
|
"GITEA_USER",
|
||||||
|
"GITEA_PASS",
|
||||||
|
"GITEA_TOKEN",
|
||||||
|
"GITEA_HOST",
|
||||||
|
"PATH",
|
||||||
|
]
|
||||||
|
for key in unreserved_keys:
|
||||||
|
self.assertFalse(
|
||||||
|
is_reserved_worktree_env_key(key),
|
||||||
|
f"Expected {key} to NOT be recognized as a reserved worktree key",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_load_env_file_sanitized_ignores_reserved_keys(self):
|
||||||
|
"""Verify .env loading ignores GITEA_ACTIVE_WORKTREE and role-specific keys (#704 AC1)."""
|
||||||
|
env_file = self.env_dir / ".env"
|
||||||
|
stale_path = "/tmp/stale-worktree-path-1234"
|
||||||
|
env_file.write_text(
|
||||||
|
f"GITEA_USER=testuser\n"
|
||||||
|
f"GITEA_ACTIVE_WORKTREE={stale_path}\n"
|
||||||
|
f"GITEA_AUTHOR_WORKTREE={stale_path}\n"
|
||||||
|
f"GITEA_REVIEWER_WORKTREE={stale_path}\n"
|
||||||
|
f"GITEA_MERGER_WORKTREE={stale_path}\n"
|
||||||
|
f"GITEA_RECONCILER_WORKTREE={stale_path}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
test_env = {}
|
||||||
|
reasons = load_env_file_sanitized(str(env_file), target_env=test_env)
|
||||||
|
|
||||||
|
# Unreserved key loaded
|
||||||
|
self.assertEqual(test_env.get("GITEA_USER"), "testuser")
|
||||||
|
|
||||||
|
# Reserved keys ignored
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", test_env)
|
||||||
|
self.assertNotIn("GITEA_AUTHOR_WORKTREE", test_env)
|
||||||
|
self.assertNotIn("GITEA_REVIEWER_WORKTREE", test_env)
|
||||||
|
self.assertNotIn("GITEA_MERGER_WORKTREE", test_env)
|
||||||
|
self.assertNotIn("GITEA_RECONCILER_WORKTREE", test_env)
|
||||||
|
|
||||||
|
# Rejection reasons populated without leaking the secret value (#704 AC5)
|
||||||
|
self.assertTrue(len(reasons) >= 5)
|
||||||
|
for r in reasons:
|
||||||
|
self.assertNotIn(stale_path, r, "Secret/path value must not leak into rejection reason")
|
||||||
|
|
||||||
|
def test_preexisting_sanctioned_launcher_env_retained(self):
|
||||||
|
"""Sanctioned launcher values in process env are retained (#704 AC2, AC3)."""
|
||||||
|
sanctioned_path = "/tmp/sanctioned-launcher-worktree"
|
||||||
|
test_env = {"GITEA_ACTIVE_WORKTREE": sanctioned_path}
|
||||||
|
|
||||||
|
env_file = self.env_dir / ".env"
|
||||||
|
env_file.write_text("GITEA_ACTIVE_WORKTREE=/tmp/injected-repo-worktree\n")
|
||||||
|
|
||||||
|
load_env_file_sanitized(str(env_file), target_env=test_env)
|
||||||
|
|
||||||
|
# Pre-existing value retained, not overwritten by .env
|
||||||
|
self.assertEqual(test_env.get("GITEA_ACTIVE_WORKTREE"), sanctioned_path)
|
||||||
|
|
||||||
|
def test_stale_or_missing_worktree_in_env_ignored(self):
|
||||||
|
"""Stale or non-existent worktree path in .env file is ignored (#704 AC7)."""
|
||||||
|
nonexistent_path = "/nonexistent/branches/stale-issue-999"
|
||||||
|
env_file = self.env_dir / ".env"
|
||||||
|
env_file.write_text(f"GITEA_ACTIVE_WORKTREE={nonexistent_path}\n")
|
||||||
|
|
||||||
|
test_env = {}
|
||||||
|
load_env_file_sanitized(str(env_file), target_env=test_env)
|
||||||
|
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", test_env)
|
||||||
|
|
||||||
|
def test_repeated_module_import_does_not_mutate_workspace_env(self):
|
||||||
|
"""Repeated imports of gitea_auth leave os.environ un-contaminated (#704 AC4, AC7)."""
|
||||||
|
# Ensure no active worktree env exists initially
|
||||||
|
original_val = os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||||
|
try:
|
||||||
|
importlib.reload(gitea_auth)
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||||
|
|
||||||
|
importlib.reload(gitea_auth)
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||||
|
finally:
|
||||||
|
if original_val is not None:
|
||||||
|
os.environ["GITEA_ACTIVE_WORKTREE"] = original_val
|
||||||
|
|
||||||
|
def test_namespace_isolation_all_roles_protected(self):
|
||||||
|
"""Verify protection across author, reviewer, merger, reconciler (#704 AC6)."""
|
||||||
|
env_file = self.env_dir / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"GITEA_AUTHOR_WORKTREE=/bad/author\n"
|
||||||
|
"GITEA_REVIEWER_WORKTREE=/bad/reviewer\n"
|
||||||
|
"GITEA_MERGER_WORKTREE=/bad/merger\n"
|
||||||
|
"GITEA_RECONCILER_WORKTREE=/bad/reconciler\n"
|
||||||
|
)
|
||||||
|
test_env = {}
|
||||||
|
load_env_file_sanitized(str(env_file), target_env=test_env)
|
||||||
|
|
||||||
|
self.assertEqual(test_env, {})
|
||||||
|
|
||||||
|
def test_absence_of_secret_leakage(self):
|
||||||
|
"""Rejection reasons contain key names but never secret path values (#704 AC5)."""
|
||||||
|
sensitive_path = "/Users/secret/path/private_repo"
|
||||||
|
env_file = self.env_dir / ".env"
|
||||||
|
env_file.write_text(f"GITEA_ACTIVE_WORKTREE={sensitive_path}\n")
|
||||||
|
|
||||||
|
test_env = {}
|
||||||
|
reasons = load_env_file_sanitized(str(env_file), target_env=test_env)
|
||||||
|
for reason in reasons:
|
||||||
|
self.assertNotIn(sensitive_path, reason)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user