harden security around attachment functionality and explicitly disallow reading sensitive files

This commit is contained in:
Taylor Wilsdon
2026-02-13 15:24:57 -05:00
parent ffba4c2f29
commit 71c054c664
4 changed files with 332 additions and 75 deletions

View File

@@ -7,6 +7,7 @@ import ssl
import asyncio
import functools
from pathlib import Path
from typing import List, Optional
from googleapiclient.errors import HttpError
@@ -29,6 +30,100 @@ class UserInputError(Exception):
pass
# Directories from which local file reads are allowed.
# The user's home directory is the default safe base.
# Override via ALLOWED_FILE_DIRS env var (colon-separated paths).
_ALLOWED_FILE_DIRS_ENV = "ALLOWED_FILE_DIRS"
def _get_allowed_file_dirs() -> list[Path]:
"""Return the list of directories from which local file access is permitted."""
env_val = os.environ.get(_ALLOWED_FILE_DIRS_ENV)
if env_val:
return [Path(p).resolve() for p in env_val.split(":") if p.strip()]
home = Path.home()
return [home] if home else []
def validate_file_path(file_path: str) -> Path:
"""
Validate that a file path is safe to read from the server filesystem.
Resolves the path canonically (following symlinks), then verifies it falls
within one of the allowed base directories. Rejects paths to sensitive
system locations regardless of allowlist.
Args:
file_path: The raw file path string to validate.
Returns:
Path: The resolved, validated Path object.
Raises:
ValueError: If the path is outside allowed directories or targets
a sensitive location.
FileNotFoundError: If the resolved path does not exist.
"""
resolved = Path(file_path).resolve()
# Block sensitive file patterns regardless of allowlist
resolved_str = str(resolved)
file_name = resolved.name.lower()
# Block .env files and variants (.env, .env.local, .env.production, etc.)
if file_name == ".env" or file_name.startswith(".env."):
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
".env files may contain secrets and cannot be read, uploaded, or attached."
)
# Block well-known sensitive system paths (including macOS /private variants)
sensitive_prefixes = (
"/proc", "/sys", "/dev",
"/etc/shadow", "/etc/passwd",
"/private/etc/shadow", "/private/etc/passwd",
)
for prefix in sensitive_prefixes:
if resolved_str == prefix or resolved_str.startswith(prefix + "/"):
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
"path is in a restricted system location."
)
# Block other credential/secret file patterns
sensitive_names = {
".credentials", ".credentials.json", "credentials.json",
"client_secret.json", "client_secrets.json",
"service_account.json", "service-account.json",
".npmrc", ".pypirc", ".netrc", ".docker/config.json",
}
if file_name in sensitive_names:
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
"this file commonly contains secrets or credentials."
)
allowed_dirs = _get_allowed_file_dirs()
if not allowed_dirs:
raise ValueError(
"No allowed file directories configured. "
"Set the ALLOWED_FILE_DIRS environment variable or ensure a home directory exists."
)
for allowed in allowed_dirs:
try:
resolved.relative_to(allowed)
return resolved
except ValueError:
continue
raise ValueError(
f"Access to '{resolved_str}' is not allowed: "
f"path is outside permitted directories ({', '.join(str(d) for d in allowed_dirs)}). "
"Set ALLOWED_FILE_DIRS to adjust."
)
def check_credentials_directory_permissions(credentials_dir: str = None) -> None:
"""
Check if the service has appropriate permissions to create and write to the .credentials directory.