Merge commit from fork

* feat: add server side pepper

* fix(api): disallow plugin installation by default
This commit is contained in:
Yunlu Wen
2026-10-13 13:46:46 +08:00
committed by GitHub
parent 667e2f8e2f
commit ed772cfcf7
10 changed files with 127 additions and 8 deletions
+6
View File
@@ -4,6 +4,12 @@
# Alternatively you can set it with `SECRET_KEY` environment variable.
SECRET_KEY=
# Optional server-side secret mixed into account password hashes. Leave empty to keep the legacy hashing.
# WARNING: setting, changing, or removing this value changes every password hash and existing passwords
# will stop working. Do not change it casually.
# Use a high-entropy random value, e.g. generate one with `openssl rand -base64 32`.
PASSWORD_PEPPER=
# Ensure UTF-8 encoding
LANG=en_US.UTF-8
LC_ALL=en_US.UTF-8
+9
View File
@@ -76,6 +76,15 @@ class SecurityConfig(BaseSettings):
default=None,
)
PASSWORD_PEPPER: str = Field(
description="Server-side secret mixed into account password hashes. "
"Leave empty to keep the legacy unpeppered hashing. "
"Use a high-entropy random value, e.g. `openssl rand -base64 32`. "
"WARNING: setting, changing, or removing this value changes every password hash, "
"so existing passwords will no longer verify. Do not change it casually.",
default="",
)
class AppExecutionConfig(BaseSettings):
"""
@@ -34,12 +34,13 @@ def plugin_permission_required(
.limit(1)
)
if not permission:
# no permission set, allow access for everyone
return view(*args, **kwargs)
install_permission = (
permission.install_permission if permission else TenantPluginInstallPermission.ADMINS
)
debug_permission = permission.debug_permission if permission else TenantPluginDebugPermission.EVERYONE
if install_required:
match permission.install_permission:
match install_permission:
case TenantPluginInstallPermission.NOBODY:
raise Forbidden()
case TenantPluginInstallPermission.ADMINS:
@@ -49,7 +50,7 @@ def plugin_permission_required(
pass
if debug_required:
match permission.debug_permission:
match debug_permission:
case TenantPluginDebugPermission.NOBODY:
raise Forbidden()
case TenantPluginDebugPermission.ADMINS:
+1 -1
View File
@@ -1118,7 +1118,7 @@ class PluginFetchPermissionApi(Resource):
if not permission:
return jsonable_encoder(
{
"install_permission": TenantPluginInstallPermission.EVERYONE,
"install_permission": TenantPluginInstallPermission.ADMINS,
"debug_permission": TenantPluginDebugPermission.EVERYONE,
}
)
+6
View File
@@ -1,8 +1,11 @@
import base64
import binascii
import hashlib
import hmac
import re
from configs import dify_config
password_pattern = r"^(?=.*[a-zA-Z])(?=.*\d).{8,}$"
@@ -18,6 +21,9 @@ def valid_password(password):
def hash_password(password_str: str, salt_byte: bytes):
dk = hashlib.pbkdf2_hmac("sha256", password_str.encode("utf-8"), salt_byte, 10000)
# The pepper wraps the legacy PBKDF2 digest, so changing PASSWORD_PEPPER invalidates every stored hash.
if pepper := dify_config.PASSWORD_PEPPER:
dk = hmac.new(pepper.encode("utf-8"), dk, hashlib.sha256).digest()
return binascii.hexlify(dk)
+1 -1
View File
@@ -315,7 +315,7 @@ class TenantPluginPermission(TypeBase):
install_permission: Mapped[TenantPluginInstallPermission] = mapped_column(
EnumText(TenantPluginInstallPermission, length=16),
nullable=False,
default=TenantPluginInstallPermission.EVERYONE,
default=TenantPluginInstallPermission.ADMINS,
)
debug_permission: Mapped[TenantPluginDebugPermission] = mapped_column(
EnumText(TenantPluginDebugPermission, length=16),
@@ -60,6 +60,52 @@ class TestPluginPermissionRequired:
assert handler() == "ok"
def test_install_without_permission_forbids_non_admin(self, db_session_with_containers: Session):
tenant = _create_tenant(db_session_with_containers)
user = SimpleNamespace(is_admin_or_owner=False)
with patch(
"controllers.console.workspace.current_account_with_tenant",
return_value=(user, tenant.id),
):
@plugin_permission_required(install_required=True)
def handler():
return "ok"
with pytest.raises(Forbidden):
handler()
def test_install_without_permission_allows_admin(self, db_session_with_containers: Session):
tenant = _create_tenant(db_session_with_containers)
user = SimpleNamespace(is_admin_or_owner=True)
with patch(
"controllers.console.workspace.current_account_with_tenant",
return_value=(user, tenant.id),
):
@plugin_permission_required(install_required=True)
def handler():
return "ok"
assert handler() == "ok"
def test_debug_without_permission_allows_non_admin(self, db_session_with_containers: Session):
tenant = _create_tenant(db_session_with_containers)
user = SimpleNamespace(is_admin_or_owner=False)
with patch(
"controllers.console.workspace.current_account_with_tenant",
return_value=(user, tenant.id),
):
@plugin_permission_required(debug_required=True)
def handler():
return "ok"
assert handler() == "ok"
def test_install_nobody_forbidden(self, db_session_with_containers: Session):
tenant = _create_tenant(db_session_with_containers)
_create_permission(
@@ -754,7 +754,8 @@ class TestPluginFetchPermissionApi:
):
result = method(api, "t1")
assert result["install_permission"] is not None
assert result["install_permission"] == TenantPluginInstallPermission.ADMINS
assert result["debug_permission"] == TenantPluginDebugPermission.EVERYONE
class TestPluginFetchDynamicSelectOptionsApi:
@@ -1,6 +1,9 @@
import base64
import binascii
import hashlib
import hmac
import os
from collections.abc import Callable
import pytest
@@ -79,3 +82,44 @@ class TestPasswordHashing:
"""Test password case sensitivity"""
result = compare_password(self.password.upper(), self.password_hash_base64, self.salt_base64)
assert result is False
class TestPasswordPepper:
"""Test PASSWORD_PEPPER handling"""
password = "test123password"
salt = b"0123456789abcdef"
@staticmethod
def _encode(password_hash: bytes) -> str:
return base64.b64encode(password_hash).decode()
def test_empty_pepper_keeps_legacy_hash(self, config_overrides: Callable[..., None]):
config_overrides(PASSWORD_PEPPER="")
legacy = binascii.hexlify(hashlib.pbkdf2_hmac("sha256", self.password.encode(), self.salt, 10000))
assert hash_password(self.password, self.salt) == legacy
def test_pepper_wraps_legacy_digest(self, config_overrides: Callable[..., None]):
config_overrides(PASSWORD_PEPPER="pepper-a")
legacy_digest = hashlib.pbkdf2_hmac("sha256", self.password.encode(), self.salt, 10000)
expected = binascii.hexlify(hmac.new(b"pepper-a", legacy_digest, hashlib.sha256).digest())
assert hash_password(self.password, self.salt) == expected
def test_peppered_hash_verifies_with_same_pepper(self, config_overrides: Callable[..., None]):
config_overrides(PASSWORD_PEPPER="pepper-a")
password_hash = self._encode(hash_password(self.password, self.salt))
assert compare_password(self.password, password_hash, self._encode(self.salt)) is True
assert compare_password("wrongpassword1", password_hash, self._encode(self.salt)) is False
@pytest.mark.parametrize(("hash_pepper", "verify_pepper"), [("", "pepper-a"), ("pepper-a", ""), ("pepper-a", "b")])
def test_changing_pepper_invalidates_existing_hash(
self, config_overrides: Callable[..., None], hash_pepper: str, verify_pepper: str
):
config_overrides(PASSWORD_PEPPER=hash_pepper)
password_hash = self._encode(hash_password(self.password, self.salt))
config_overrides(PASSWORD_PEPPER=verify_pepper)
assert compare_password(self.password, password_hash, self._encode(self.salt)) is False
@@ -11,6 +11,12 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
PLUGIN_MAX_PACKAGE_SIZE=52428800
PLUGIN_DAEMON_TIMEOUT=600.0
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
# Optional server-side secret mixed into account password hashes. Leave empty to keep the legacy hashing.
# WARNING: setting, changing, or removing this value changes every password hash and existing passwords
# will stop working. Do not change it casually.
# Use a high-entropy random value, e.g. generate one with `openssl rand -base64 32`.
# Keep it in this file rather than docker/.env, which is also loaded by plugin_daemon and agent_backend.
PASSWORD_PEPPER=
# Client deadline for converting a Binding file to a ToolFile through the Agent backend.
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=240
KNOWLEDGE_FS_ENABLED=${KNOWLEDGE_FS_ENABLED:-false}