fix(security): harden self-hosted SECRET_KEY bootstrap (#36049)

Co-authored-by: EndlessLucky <66432853+EndlessLucky@users.noreply.github.com>
This commit is contained in:
-LAN- 2026-05-12 13:35:24 +08:00 committed by GitHub
parent 1a93af5cd0
commit cbedcd2882
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 209 additions and 84 deletions

View File

@ -181,7 +181,6 @@ def initialize_extensions(app: DifyApp):
ext_import_modules,
ext_orjson,
ext_forward_refs,
ext_set_secretkey,
ext_compress,
ext_code_based_extension,
ext_database,
@ -189,6 +188,7 @@ def initialize_extensions(app: DifyApp):
ext_migrate,
ext_redis,
ext_storage,
ext_set_secretkey,
ext_logstore, # Initialize logstore after storage, before celery
ext_celery,
ext_login,

View File

@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings):
"""
SECRET_KEY: str = Field(
description="Secret key for secure session cookie signing."
"Make sure you are changing this key for your deployment with a strong key."
"Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.",
description="Secret key for secure session cookie signing. "
"Leave empty to let Dify generate a persistent key in the storage directory, "
"or set a strong value via the `SECRET_KEY` environment variable.",
default="",
)

38
api/configs/secret_key.py Normal file
View File

@ -0,0 +1,38 @@
"""SECRET_KEY persistence helpers for runtime setup."""
from __future__ import annotations
import secrets
from extensions.ext_storage import storage
GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key"
def resolve_secret_key(secret_key: str) -> str:
"""Return an explicit SECRET_KEY or a generated key persisted in storage."""
if secret_key:
return secret_key
return _load_or_create_secret_key()
def _load_or_create_secret_key() -> str:
try:
persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip()
if persisted_key:
return persisted_key
except FileNotFoundError:
pass
generated_key = secrets.token_urlsafe(48)
try:
storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode())
except Exception as exc:
raise ValueError(
f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. "
"Set SECRET_KEY explicitly or make storage writable."
) from exc
return generated_key

View File

@ -128,7 +128,7 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
@staticmethod
def _secret_key() -> bytes:
return dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
return dify_config.SECRET_KEY.encode()
def _sign_query(self, *, payload: str) -> dict[str, str]:
timestamp = str(int(time.time()))

View File

@ -35,8 +35,11 @@ class DatasourceFileManager:
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
sign = hmac.new(
dify_config.SECRET_KEY.encode(),
data_to_sign.encode(),
hashlib.sha256,
).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
@ -47,8 +50,11 @@ class DatasourceFileManager:
verify signature
"""
data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
recalculated_sign = hmac.new(
dify_config.SECRET_KEY.encode(),
data_to_sign.encode(),
hashlib.sha256,
).digest()
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
# verify signature

View File

@ -8,6 +8,10 @@ import urllib.parse
from configs import dify_config
def _secret_key() -> bytes:
return dify_config.SECRET_KEY.encode()
def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str:
"""
sign file to get a temporary url for plugin access
@ -19,8 +23,7 @@ def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True)
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
@ -39,8 +42,7 @@ def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str:
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
@ -51,8 +53,7 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
verify signature
"""
data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
# verify signature
@ -71,8 +72,7 @@ def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str,
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
query = urllib.parse.urlencode(
{
@ -92,8 +92,7 @@ def verify_plugin_file_signature(
"""Verify the signature used by the plugin-facing file upload endpoint."""
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
if sign != recalculated_encoded_sign:

View File

@ -51,8 +51,11 @@ class ToolFileManager:
timestamp = str(int(time.time()))
nonce = os.urandom(16).hex()
data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
sign = hmac.new(
dify_config.SECRET_KEY.encode(),
data_to_sign.encode(),
hashlib.sha256,
).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
@ -63,8 +66,11 @@ class ToolFileManager:
verify signature
"""
data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
recalculated_sign = hmac.new(
dify_config.SECRET_KEY.encode(),
data_to_sign.encode(),
hashlib.sha256,
).digest()
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
# verify signature

View File

@ -1,6 +1,13 @@
from configs import dify_config
from configs.secret_key import resolve_secret_key
from dify_app import DifyApp
def init_app(app: DifyApp):
app.secret_key = dify_config.SECRET_KEY
def init_app(app: DifyApp) -> None:
"""Resolve SECRET_KEY after config loading and before session/login setup."""
secret_key = dify_config.SECRET_KEY
if not secret_key:
secret_key = resolve_secret_key(secret_key)
dify_config.SECRET_KEY = secret_key
app.config["SECRET_KEY"] = secret_key
app.secret_key = secret_key

View File

@ -945,7 +945,7 @@ class DocumentSegment(Base):
nonce = os.urandom(16).hex()
timestamp = str(int(time.time()))
data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
secret_key = dify_config.SECRET_KEY.encode()
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
@ -962,7 +962,7 @@ class DocumentSegment(Base):
nonce = os.urandom(16).hex()
timestamp = str(int(time.time()))
data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
secret_key = dify_config.SECRET_KEY.encode()
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
@ -981,7 +981,7 @@ class DocumentSegment(Base):
nonce = os.urandom(16).hex()
timestamp = str(int(time.time()))
data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
secret_key = dify_config.SECRET_KEY.encode()
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()
@ -1019,7 +1019,7 @@ class DocumentSegment(Base):
nonce = os.urandom(16).hex()
timestamp = str(int(time.time()))
data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}"
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
secret_key = dify_config.SECRET_KEY.encode()
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
encoded_sign = base64.urlsafe_b64encode(sign).decode()

View File

@ -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()

View File

@ -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")

View File

@ -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 == []

View File

@ -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):

View File

@ -28,7 +28,8 @@ LANG=C.UTF-8
LC_ALL=C.UTF-8
PYTHONIOENCODING=utf-8
UV_CACHE_DIR=/tmp/.uv-cache
SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U
# Leave empty to auto-generate a persistent key in the storage directory.
SECRET_KEY=
INIT_PASSWORD=
DEPLOY_ENV=PRODUCTION
CHECK_UPDATE_URL=https://updates.dify.ai

View File

@ -87,7 +87,7 @@ The root `.env.example` file contains the essential startup settings. Optional a
1. **Server Configuration**:
- `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings.
- `SECRET_KEY`: A key for encrypting session cookies and other sensitive data.
- `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself.
1. **Database Configuration**:

View File

@ -36,5 +36,6 @@ TIDB_PUBLIC_KEY=dify
TIDB_PRIVATE_KEY=dify
VIKINGDB_ACCESS_KEY=your-ak
VIKINGDB_SECRET_KEY=your-sk
SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U
# Leave empty to auto-generate a persistent key in the storage directory.
SECRET_KEY=
INIT_PASSWORD=