Compare commits
6 Commits
fix/sqlite
...
security/f
| Author | SHA1 | Date | |
|---|---|---|---|
| cfaf6c827e | |||
| cf1afb07f2 | |||
| ed32487cbe | |||
| 37c5e672b5 | |||
| cfcffd38ab | |||
| 0b49540db3 |
@@ -292,7 +292,29 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
extra = config.extra or {}
|
||||
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
|
||||
self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))))
|
||||
|
||||
# SECURITY FIX (V-009): Fail-secure default for API key
|
||||
# Previously: Empty API key allowed all requests (dangerous default)
|
||||
# Now: Require explicit "allow_unauthenticated" setting to disable auth
|
||||
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
|
||||
self._allow_unauthenticated: bool = extra.get(
|
||||
"allow_unauthenticated",
|
||||
os.getenv("API_SERVER_ALLOW_UNAUTHENTICATED", "").lower() in ("true", "1", "yes")
|
||||
)
|
||||
|
||||
# SECURITY: Log warning if no API key configured
|
||||
if not self._api_key and not self._allow_unauthenticated:
|
||||
logger.warning(
|
||||
"API_SERVER_KEY not configured. All requests will be rejected. "
|
||||
"Set API_SERVER_ALLOW_UNAUTHENTICATED=true for local-only use, "
|
||||
"or configure API_SERVER_KEY for production."
|
||||
)
|
||||
elif not self._api_key and self._allow_unauthenticated:
|
||||
logger.warning(
|
||||
"API_SERVER running without authentication. "
|
||||
"This is only safe for local-only deployments."
|
||||
)
|
||||
|
||||
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
||||
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
|
||||
)
|
||||
@@ -317,15 +339,22 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return tuple(str(item).strip() for item in items if str(item).strip())
|
||||
|
||||
def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
|
||||
"""Return CORS headers for an allowed browser origin."""
|
||||
"""Return CORS headers for an allowed browser origin.
|
||||
|
||||
SECURITY FIX (V-008): Never allow wildcard "*" with credentials.
|
||||
If "*" is configured, we reject the request to prevent security issues.
|
||||
"""
|
||||
if not origin or not self._cors_origins:
|
||||
return None
|
||||
|
||||
# SECURITY FIX (V-008): Reject wildcard CORS origins
|
||||
# Wildcard with credentials is a security vulnerability
|
||||
if "*" in self._cors_origins:
|
||||
headers = dict(_CORS_HEADERS)
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Max-Age"] = "600"
|
||||
return headers
|
||||
logger.warning(
|
||||
"CORS wildcard '*' is not allowed for security reasons. "
|
||||
"Please configure specific origins in API_SERVER_CORS_ORIGINS."
|
||||
)
|
||||
return None # Reject wildcard - too dangerous
|
||||
|
||||
if origin not in self._cors_origins:
|
||||
return None
|
||||
@@ -355,10 +384,22 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
Validate Bearer token from Authorization header.
|
||||
|
||||
Returns None if auth is OK, or a 401 web.Response on failure.
|
||||
If no API key is configured, all requests are allowed.
|
||||
|
||||
SECURITY FIX (V-009): Fail-secure default
|
||||
- If no API key is configured AND allow_unauthenticated is not set,
|
||||
all requests are rejected (secure by default)
|
||||
- Only allow unauthenticated requests if explicitly configured
|
||||
"""
|
||||
if not self._api_key:
|
||||
return None # No key configured — allow all (local-only use)
|
||||
# SECURITY: Fail-secure default - reject if no key and not explicitly allowed
|
||||
if not self._api_key and not self._allow_unauthenticated:
|
||||
return web.json_response(
|
||||
{"error": {"message": "Authentication required. Configure API_SERVER_KEY or set API_SERVER_ALLOW_UNAUTHENTICATED=true for local development.", "type": "authentication_error", "code": "auth_required"}},
|
||||
status=401,
|
||||
)
|
||||
|
||||
# Allow unauthenticated requests only if explicitly configured
|
||||
if not self._api_key and self._allow_unauthenticated:
|
||||
return None # Explicitly allowed for local-only use
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
|
||||
@@ -170,6 +170,9 @@ def _resolve_cdp_override(cdp_url: str) -> str:
|
||||
For discovery-style endpoints we fetch /json/version and return the
|
||||
webSocketDebuggerUrl so downstream tools always receive a concrete browser
|
||||
websocket instead of an ambiguous host:port URL.
|
||||
|
||||
SECURITY FIX (V-010): Validates URLs before fetching to prevent SSRF.
|
||||
Only allows localhost/private network addresses for CDP connections.
|
||||
"""
|
||||
raw = (cdp_url or "").strip()
|
||||
if not raw:
|
||||
@@ -191,6 +194,35 @@ def _resolve_cdp_override(cdp_url: str) -> str:
|
||||
else:
|
||||
version_url = discovery_url.rstrip("/") + "/json/version"
|
||||
|
||||
# SECURITY FIX (V-010): Validate URL before fetching
|
||||
# Only allow localhost and private networks for CDP
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(version_url)
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
# Allow only safe hostnames for CDP
|
||||
allowed_hostnames = ["localhost", "127.0.0.1", "0.0.0.0", "::1"]
|
||||
if hostname not in allowed_hostnames:
|
||||
# Check if it's a private IP
|
||||
try:
|
||||
import ipaddress
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
if not (ip.is_private or ip.is_loopback):
|
||||
logger.error(
|
||||
"SECURITY: Rejecting CDP URL '%s' - only localhost and private "
|
||||
"networks are allowed to prevent SSRF attacks.",
|
||||
raw
|
||||
)
|
||||
return raw # Return original without fetching
|
||||
except ValueError:
|
||||
# Not an IP - reject unknown hostnames
|
||||
logger.error(
|
||||
"SECURITY: Rejecting CDP URL '%s' - unknown hostname '%s'. "
|
||||
"Only localhost and private IPs are allowed.",
|
||||
raw, hostname
|
||||
)
|
||||
return raw
|
||||
|
||||
try:
|
||||
response = requests.get(version_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -253,6 +253,26 @@ class DockerEnvironment(BaseEnvironment):
|
||||
# mode uses tmpfs (ephemeral, fast, gone on cleanup).
|
||||
from tools.environments.base import get_sandbox_dir
|
||||
|
||||
# SECURITY FIX (V-012): Block dangerous volume mounts
|
||||
# Prevent privilege escalation via Docker socket or sensitive paths
|
||||
_BLOCKED_VOLUME_PATTERNS = [
|
||||
"/var/run/docker.sock",
|
||||
"/run/docker.sock",
|
||||
"/var/run/docker.pid",
|
||||
"/proc", "/sys", "/dev",
|
||||
":/", # Root filesystem mount
|
||||
]
|
||||
|
||||
def _is_dangerous_volume(vol_spec: str) -> bool:
|
||||
"""Check if volume spec is dangerous (docker socket, root fs, etc)."""
|
||||
for pattern in _BLOCKED_VOLUME_PATTERNS:
|
||||
if pattern in vol_spec:
|
||||
return True
|
||||
# Check for docker socket variations
|
||||
if "docker.sock" in vol_spec.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
# User-configured volume mounts (from config.yaml docker_volumes)
|
||||
volume_args = []
|
||||
workspace_explicitly_mounted = False
|
||||
@@ -263,6 +283,15 @@ class DockerEnvironment(BaseEnvironment):
|
||||
vol = vol.strip()
|
||||
if not vol:
|
||||
continue
|
||||
|
||||
# SECURITY FIX (V-012): Block dangerous volumes
|
||||
if _is_dangerous_volume(vol):
|
||||
logger.error(
|
||||
f"SECURITY: Refusing to mount dangerous volume '{vol}'. "
|
||||
f"Docker socket and system paths are blocked to prevent container escape."
|
||||
)
|
||||
continue # Skip this dangerous volume
|
||||
|
||||
if ":" in vol:
|
||||
volume_args.extend(["-v", vol])
|
||||
if ":/workspace" in vol:
|
||||
|
||||
Reference in New Issue
Block a user