release: v3.3.0 — platform hardening & admin unification (Track F.1–F.8)
Plugin visibility control, UI/UX fixes, unified admin panel, database-backed settings, and security hardening. Highlights: - ENABLED_PLUGINS env var for plugin visibility (F.1) - Admin designation via ADMIN_EMAILS (F.4a) - Master key scope control (F.4b) - Panel unification + settings from UI (F.4c) - exec() removal, shell injection fix, bcrypt migration (F.8) - 5 new test suites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ class SupabaseClient:
|
||||
anon_key: str,
|
||||
service_role_key: str,
|
||||
meta_url: str | None = None,
|
||||
meta_auth: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Supabase API client.
|
||||
@@ -45,12 +46,16 @@ class SupabaseClient:
|
||||
meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555).
|
||||
When provided, postgres-meta calls hit this URL directly instead of
|
||||
the Kong /pg/ route. Useful when /pg/ is not exposed through Kong.
|
||||
meta_auth: Optional Basic Auth credentials for postgres-meta (username:password).
|
||||
When provided, requests to meta_base_url use Basic Auth instead of JWT.
|
||||
Recommended when postgres-meta is exposed via a public URL.
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.anon_key = anon_key
|
||||
self.service_role_key = service_role_key
|
||||
# postgres-meta base: custom URL or Kong /pg/ prefix
|
||||
self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/")
|
||||
self.meta_auth = meta_auth
|
||||
|
||||
# Initialize logger
|
||||
self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
|
||||
@@ -83,6 +88,23 @@ class SupabaseClient:
|
||||
|
||||
return headers
|
||||
|
||||
def _get_meta_headers(self, additional_headers: dict | None = None) -> dict[str, str]:
|
||||
"""Get headers for postgres-meta requests (Basic Auth if configured, else JWT)."""
|
||||
headers: dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self.meta_auth:
|
||||
encoded = base64.b64encode(self.meta_auth.encode()).decode()
|
||||
headers["Authorization"] = f"Basic {encoded}"
|
||||
else:
|
||||
key = self.service_role_key
|
||||
headers["apikey"] = key
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
return headers
|
||||
|
||||
async def _head_request_headers(
|
||||
self,
|
||||
endpoint: str,
|
||||
@@ -140,7 +162,11 @@ class SupabaseClient:
|
||||
"""
|
||||
url = f"{base_url_override or self.base_url}{endpoint}"
|
||||
|
||||
headers = self._get_headers(use_service_role, headers_override)
|
||||
# Use Basic Auth headers for postgres-meta requests when meta_auth is configured
|
||||
if base_url_override and base_url_override == self.meta_base_url:
|
||||
headers = self._get_meta_headers(headers_override)
|
||||
else:
|
||||
headers = self._get_headers(use_service_role, headers_override)
|
||||
|
||||
# Remove Content-Type for binary data
|
||||
if data is not None:
|
||||
@@ -163,7 +189,7 @@ class SupabaseClient:
|
||||
|
||||
if params:
|
||||
kwargs["params"] = params
|
||||
if json_data:
|
||||
if json_data is not None:
|
||||
kwargs["json"] = json_data
|
||||
if data:
|
||||
kwargs["data"] = data
|
||||
@@ -681,7 +707,10 @@ class SupabaseClient:
|
||||
async def empty_bucket(self, bucket_id: str) -> dict:
|
||||
"""Empty a bucket (delete all files)."""
|
||||
return await self.request(
|
||||
"POST", f"/storage/v1/bucket/{bucket_id}/empty", use_service_role=True
|
||||
"POST",
|
||||
f"/storage/v1/bucket/{bucket_id}/empty",
|
||||
json_data={},
|
||||
use_service_role=True,
|
||||
)
|
||||
|
||||
async def list_files(
|
||||
|
||||
@@ -241,13 +241,6 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "get_auth_config",
|
||||
"method_name": "get_auth_config",
|
||||
"description": "Get current GoTrue authentication configuration.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "search_users",
|
||||
"method_name": "search_users",
|
||||
@@ -490,17 +483,6 @@ async def delete_user_factor(client: SupabaseClient, user_id: str, factor_id: st
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
async def get_auth_config(client: SupabaseClient) -> str:
|
||||
"""Get auth configuration"""
|
||||
try:
|
||||
# Get health which includes some config info
|
||||
result = await client.request("GET", "/auth/v1/health", use_service_role=False)
|
||||
|
||||
return json.dumps({"success": True, "config": result}, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
async def search_users(
|
||||
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
|
||||
) -> str:
|
||||
|
||||
@@ -59,6 +59,7 @@ class SupabasePlugin(BasePlugin):
|
||||
- service_role_key: Admin API key (bypasses RLS). Required.
|
||||
- anon_key: Public API key (RLS protected). Optional.
|
||||
- meta_url: Direct postgres-meta URL. Optional.
|
||||
- meta_auth: Basic Auth for postgres-meta (username:password). Optional.
|
||||
project_id: Optional project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
@@ -69,6 +70,7 @@ class SupabasePlugin(BasePlugin):
|
||||
anon_key=config.get("anon_key", ""),
|
||||
service_role_key=config["service_role_key"],
|
||||
meta_url=config.get("meta_url"),
|
||||
meta_auth=config.get("meta_auth"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -76,9 +76,13 @@ class WPCLIManager:
|
||||
"""
|
||||
try:
|
||||
# First, test if we have Docker socket access
|
||||
test_cmd = "docker version --format '{{.Server.Version}}'"
|
||||
test_process = await asyncio.create_subprocess_shell(
|
||||
test_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
test_process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
test_stdout, test_stderr = await asyncio.wait_for(
|
||||
@@ -98,11 +102,16 @@ class WPCLIManager:
|
||||
self.logger.debug(f"Docker access OK - Server version: {docker_version}")
|
||||
|
||||
# Now check for our specific container using exact name match
|
||||
# Use --all to include stopped containers and provide better error message
|
||||
cmd = f"docker ps --all --filter name=^{self.container_name}$ --format '{{{{.Names}}}}|{{{{.Status}}}}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"ps",
|
||||
"--all",
|
||||
"--filter",
|
||||
f"name=^{self.container_name}$",
|
||||
"--format",
|
||||
"{{.Names}}|{{.Status}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
@@ -116,9 +125,14 @@ class WPCLIManager:
|
||||
|
||||
if not output:
|
||||
# Container not found - get list of available containers for helpful error
|
||||
list_cmd = "docker ps --all --format '{{.Names}}' | head -10"
|
||||
list_process = await asyncio.create_subprocess_shell(
|
||||
list_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
list_process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"ps",
|
||||
"--all",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
list_stdout, _ = await list_process.communicate()
|
||||
available = list_stdout.decode().strip().split("\n") if list_stdout else []
|
||||
@@ -169,10 +183,15 @@ class WPCLIManager:
|
||||
|
||||
try:
|
||||
# Try to run wp --version
|
||||
cmd = f"docker exec {self.container_name} wp --version --allow-root"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_name,
|
||||
"wp",
|
||||
"--version",
|
||||
"--allow-root",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
@@ -290,15 +309,17 @@ class WPCLIManager:
|
||||
f"Please install WP-CLI in your WordPress container."
|
||||
)
|
||||
|
||||
# 4. Build docker exec command
|
||||
docker_cmd = f"docker exec {self.container_name} wp {command} --allow-root"
|
||||
# 4. Build docker exec command (split command into args to prevent shell injection)
|
||||
cmd_parts = (
|
||||
["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
|
||||
)
|
||||
|
||||
self.logger.info(f"Executing: {docker_cmd}")
|
||||
self.logger.info(f"Executing: {' '.join(cmd_parts)}")
|
||||
|
||||
try:
|
||||
# 5. Execute command
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
docker_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd_parts, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
# 6. Wait for completion with timeout
|
||||
@@ -580,13 +601,33 @@ class WPCLIManager:
|
||||
# Get file size if export succeeded
|
||||
# Try to check file size via docker exec
|
||||
try:
|
||||
size_cmd = f"docker exec {self.container_name} stat -f %z {export_path} 2>/dev/null || docker exec {self.container_name} stat -c %s {export_path} 2>/dev/null"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
size_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
# Try GNU stat first, fall back to BSD stat
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_name,
|
||||
"stat",
|
||||
"-c",
|
||||
"%s",
|
||||
export_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
if process.returncode != 0:
|
||||
# BSD stat fallback
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_name,
|
||||
"stat",
|
||||
"-f",
|
||||
"%z",
|
||||
export_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
|
||||
if process.returncode == 0:
|
||||
size_bytes = int(stdout.decode().strip())
|
||||
|
||||
Reference in New Issue
Block a user