mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
fix(security): harden self-hosted SECRET_KEY bootstrap (#36049)
Co-authored-by: EndlessLucky <66432853+EndlessLucky@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,47 @@ from yarl import URL
|
||||
from configs.app_config import DifyConfig
|
||||
|
||||
|
||||
def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
os.environ.clear()
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
monkeypatch.setenv("DB_USERNAME", "postgres")
|
||||
monkeypatch.setenv("DB_PASSWORD", "postgres")
|
||||
monkeypatch.setenv("DB_HOST", "localhost")
|
||||
monkeypatch.setenv("DB_PORT", "5432")
|
||||
monkeypatch.setenv("DB_DATABASE", "dify")
|
||||
|
||||
|
||||
def test_dify_config_keeps_secret_key_empty_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path))
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.SECRET_KEY == ""
|
||||
assert not hasattr(config, "OPENDAL_FS_ROOT")
|
||||
assert not (tmp_path / ".dify_secret_key").exists()
|
||||
|
||||
|
||||
def test_dify_config_preserves_explicit_secret_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("SECRET_KEY", "explicit")
|
||||
monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path))
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.SECRET_KEY == "explicit"
|
||||
assert not (tmp_path / ".dify_secret_key").exists()
|
||||
|
||||
|
||||
def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
|
||||
@@ -34,20 +34,6 @@ class TestDatasourceFileManager:
|
||||
assert f"nonce={mock_urandom.return_value.hex()}" in signed_url
|
||||
assert "sign=" in signed_url
|
||||
|
||||
@patch("core.datasource.datasource_file_manager.time.time")
|
||||
@patch("core.datasource.datasource_file_manager.os.urandom")
|
||||
@patch("core.datasource.datasource_file_manager.dify_config")
|
||||
def test_sign_file_empty_secret(self, mock_config, mock_urandom, mock_time):
|
||||
# Setup
|
||||
mock_config.FILES_URL = "http://localhost:5001"
|
||||
mock_config.SECRET_KEY = None # Empty secret
|
||||
mock_time.return_value = 1700000000
|
||||
mock_urandom.return_value = b"1234567890abcdef"
|
||||
|
||||
# Execute
|
||||
signed_url = DatasourceFileManager.sign_file("file_id", ".png")
|
||||
assert "sign=" in signed_url
|
||||
|
||||
@patch("core.datasource.datasource_file_manager.time.time")
|
||||
@patch("core.datasource.datasource_file_manager.dify_config")
|
||||
def test_verify_file(self, mock_config, mock_time):
|
||||
@@ -76,25 +62,6 @@ class TestDatasourceFileManager:
|
||||
mock_time.return_value = 1700000500 # 700 seconds after timestamp (300 is timeout)
|
||||
assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is False
|
||||
|
||||
@patch("core.datasource.datasource_file_manager.time.time")
|
||||
@patch("core.datasource.datasource_file_manager.dify_config")
|
||||
def test_verify_file_empty_secret(self, mock_config, mock_time):
|
||||
# Setup
|
||||
mock_config.SECRET_KEY = "" # Empty string secret
|
||||
mock_config.FILES_ACCESS_TIMEOUT = 300
|
||||
mock_time.return_value = 1700000000
|
||||
|
||||
datasource_file_id = "file_id_123"
|
||||
timestamp = "1699999800"
|
||||
nonce = "some_nonce"
|
||||
|
||||
# Calculate with empty secret
|
||||
data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}"
|
||||
sign = hmac.new(b"", data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is True
|
||||
|
||||
@patch("core.datasource.datasource_file_manager.db")
|
||||
@patch("core.datasource.datasource_file_manager.storage")
|
||||
@patch("core.datasource.datasource_file_manager.uuid4")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from extensions import ext_set_secretkey
|
||||
|
||||
|
||||
class InMemoryStorage:
|
||||
def __init__(self, files: dict[str, bytes] | None = None) -> None:
|
||||
self.files = files or {}
|
||||
self.saved_files: list[tuple[str, bytes]] = []
|
||||
|
||||
def load_once(self, filename: str) -> bytes:
|
||||
try:
|
||||
return self.files[filename]
|
||||
except KeyError:
|
||||
raise FileNotFoundError(filename)
|
||||
|
||||
def save(self, filename: str, data: bytes) -> None:
|
||||
self.files[filename] = data
|
||||
self.saved_files.append((filename, data))
|
||||
|
||||
|
||||
def test_init_app_uses_configured_secret_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
secret_key = "configured-secret-key"
|
||||
storage = InMemoryStorage()
|
||||
monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", secret_key)
|
||||
monkeypatch.setattr("configs.secret_key.storage", storage)
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = secret_key
|
||||
|
||||
ext_set_secretkey.init_app(app)
|
||||
|
||||
assert app.secret_key == secret_key
|
||||
assert app.config["SECRET_KEY"] == secret_key
|
||||
assert storage.saved_files == []
|
||||
|
||||
|
||||
def test_init_app_generates_and_persists_secret_key_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
storage = InMemoryStorage()
|
||||
monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "")
|
||||
monkeypatch.setattr("configs.secret_key.storage", storage)
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = ""
|
||||
|
||||
ext_set_secretkey.init_app(app)
|
||||
|
||||
persisted_key = storage.files[".dify_secret_key"].decode("utf-8").strip()
|
||||
assert persisted_key
|
||||
assert storage.saved_files == [(".dify_secret_key", f"{persisted_key}\n".encode())]
|
||||
assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY
|
||||
assert persisted_key == app.config["SECRET_KEY"]
|
||||
assert persisted_key == app.secret_key
|
||||
|
||||
|
||||
def test_init_app_reuses_persisted_secret_key_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
persisted_key = "persisted-secret-key"
|
||||
storage = InMemoryStorage({".dify_secret_key": f"{persisted_key}\n".encode()})
|
||||
monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "")
|
||||
monkeypatch.setattr("configs.secret_key.storage", storage)
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = ""
|
||||
|
||||
ext_set_secretkey.init_app(app)
|
||||
|
||||
assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY
|
||||
assert persisted_key == app.config["SECRET_KEY"]
|
||||
assert persisted_key == app.secret_key
|
||||
assert storage.saved_files == []
|
||||
@@ -143,28 +143,13 @@ class TestPassportService:
|
||||
assert str(exc_info.value) == "401 Unauthorized: Token has expired."
|
||||
|
||||
# Configuration tests
|
||||
def test_should_handle_empty_secret_key(self):
|
||||
"""Test behavior when SECRET_KEY is empty"""
|
||||
def test_should_use_configured_secret_key_without_policy_validation(self):
|
||||
"""Test that policy decisions are owned by config, not PassportService."""
|
||||
with patch("libs.passport.dify_config") as mock_config:
|
||||
mock_config.SECRET_KEY = ""
|
||||
mock_config.SECRET_KEY = "configured"
|
||||
service = PassportService()
|
||||
|
||||
# Empty secret key should still work but is insecure
|
||||
payload = {"test": "data"}
|
||||
token = service.issue(payload)
|
||||
decoded = service.verify(token)
|
||||
assert decoded == payload
|
||||
|
||||
def test_should_handle_none_secret_key(self):
|
||||
"""Test behavior when SECRET_KEY is None"""
|
||||
with patch("libs.passport.dify_config") as mock_config:
|
||||
mock_config.SECRET_KEY = None
|
||||
service = PassportService()
|
||||
|
||||
payload = {"test": "data"}
|
||||
# JWT library will raise TypeError when secret is None
|
||||
with pytest.raises((TypeError, jwt.exceptions.InvalidKeyError)):
|
||||
service.issue(payload)
|
||||
assert service.sk == "configured"
|
||||
|
||||
# Boundary condition tests
|
||||
def test_should_handle_large_payload(self, passport_service):
|
||||
|
||||
Reference in New Issue
Block a user