fix: validate conversation variable description length to prevent varchar(255) truncation error (#33038)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 非法操作 <hjlarry@163.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
Novice 2026-06-10 15:28:12 +08:00 committed by GitHub
parent 09bfbf386e
commit 4fb3210f9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 206 additions and 26 deletions

View File

@ -41,3 +41,13 @@ class NoFileUploadedError(BaseHTTPException):
error_code = "no_file_uploaded"
description = "Please upload your file."
code = 400
class NotFoundError(BaseHTTPException):
error_code = "not_found"
code = 404
class InvalidArgumentError(BaseHTTPException):
error_code = "invalid_param"
code = 400

View File

@ -12,6 +12,7 @@ from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotF
import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import NewAppResponse, SimpleResultResponse
from controllers.common.schema import (
register_response_schema_model,
@ -19,7 +20,11 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.error import (
ConversationCompletedError,
DraftWorkflowNotExist,
DraftWorkflowNotSync,
)
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
@ -56,6 +61,7 @@ from graphon.file import helpers as file_helpers
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.utils.encoders import jsonable_encoder
from graphon.variables import SecretVariable, SegmentType, VariableBase
from graphon.variables.exc import VariableError
from libs import helper
from libs.datetime_utils import naive_utc_now
from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
@ -452,6 +458,8 @@ class DraftWorkflowApi(Resource):
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
except VariableError as e:
raise InvalidArgumentError(description=str(e))
return {
"result": "success",

View File

@ -9,6 +9,7 @@ from flask_restx import Resource, fields, marshal, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
@ -21,7 +22,6 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db

View File

@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from controllers.common.errors import NotFoundError
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
@ -18,7 +19,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from controllers.web.error import NotFoundError
from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id
from extensions.ext_database import db
from fields.base import ResponseModel

View File

@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import (
@ -23,7 +24,6 @@ from controllers.console.app.workflow_draft_variable import (
)
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db

View File

@ -11,6 +11,7 @@ from flask_restx import Resource
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.human_input import HumanInputFormSubmitPayload
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
@ -21,7 +22,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter

View File

@ -18,6 +18,7 @@ from flask import Response, request
from flask_restx import Resource, marshal, marshal_with
from sqlalchemy.orm import Session, sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.console import console_ns
from controllers.console.app.error import DraftWorkflowNotExist
from controllers.console.app.workflow_draft_variable import (
@ -36,7 +37,6 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db

View File

@ -8,9 +8,9 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config
from controllers.common.errors import NotFoundError
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web.error import NotFoundError
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.oauth import OAuthHandler
from core.trigger.entities.entities import SubscriptionBuilderUpdater

View File

@ -121,13 +121,3 @@ class WebFormRateLimitExceededError(BaseHTTPException):
error_code = "web_form_rate_limit_exceeded"
description = "Too many form requests. Please try again later."
code = 429
class NotFoundError(BaseHTTPException):
error_code = "not_found"
code = 404
class InvalidArgumentError(BaseHTTPException):
error_code = "invalid_param"
code = 400

View File

@ -15,10 +15,11 @@ from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.errors import NotFoundError
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
from controllers.common.schema import register_schema_models
from controllers.web import web_ns
from controllers.web.error import NotFoundError, WebFormRateLimitExceededError
from controllers.web.error import WebFormRateLimitExceededError
from controllers.web.site import serialize_app_site_payload
from extensions.ext_database import db
from graphon.nodes.human_input.entities import FormInputConfig

View File

@ -8,8 +8,8 @@ from collections.abc import Generator
from flask import Response, request
from sqlalchemy.orm import sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.web import api
from controllers.web.error import InvalidArgumentError, NotFoundError
from controllers.web.wraps import WebApiResource
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator

View File

@ -48,9 +48,18 @@ __all__ = [
]
_MAX_VARIABLE_DESCRIPTION_LENGTH = 255
def build_conversation_variable_from_mapping(mapping: Mapping[str, Any], /) -> VariableBase:
if not mapping.get("name"):
raise VariableError("missing name")
description = mapping.get("description", "")
if len(description) > _MAX_VARIABLE_DESCRIPTION_LENGTH:
raise VariableError(
f"description of variable '{mapping['name']}' is too long"
f" (max {_MAX_VARIABLE_DESCRIPTION_LENGTH} characters)"
)
return _build_variable_from_mapping(mapping=mapping, selector=[CONVERSATION_VARIABLE_NODE_ID, mapping["name"]])

View File

@ -1861,6 +1861,8 @@ class WorkflowDraftVariable(Base):
variable.file_id = file_id
variable._set_selector(list(variable_utils.to_selector(node_id, name)))
variable.node_execution_id = node_execution_id
variable.visible = True
variable.is_default_value = False
return variable
@classmethod

View File

@ -749,6 +749,7 @@ class _InsertionDict(TypedDict):
file_id: str | None
visible: NotRequired[bool]
editable: NotRequired[bool]
is_default_value: NotRequired[bool]
created_at: NotRequired[datetime]
updated_at: NotRequired[datetime]
description: NotRequired[str]
@ -778,6 +779,8 @@ def _model_to_insertion_dict(model: WorkflowDraftVariable) -> _InsertionDict:
d["updated_at"] = model.updated_at
if model.description is not None:
d["description"] = model.description
if model.is_default_value is not None:
d["is_default_value"] = model.is_default_value
return d

View File

@ -8,6 +8,7 @@ import pytest
from flask import Flask
from werkzeug.exceptions import BadRequest, Forbidden
from controllers.common.errors import NotFoundError
from controllers.console.workspace.trigger_providers import (
TriggerOAuthAuthorizeApi,
TriggerOAuthCallbackApi,
@ -26,7 +27,6 @@ from controllers.console.workspace.trigger_providers import (
TriggerSubscriptionUpdateApi,
TriggerSubscriptionVerifyApi,
)
from controllers.web.error import NotFoundError
from core.plugin.entities.plugin_daemon import CredentialType
from models.account import Account

View File

@ -12,6 +12,7 @@ from flask import Flask
from pydantic import ValidationError
from werkzeug.exceptions import HTTPException, NotFound
from controllers.common.errors import InvalidArgumentError
from controllers.console.app import workflow as workflow_module
from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync
from graphon.file import File, FileTransferMethod, FileType
@ -180,6 +181,29 @@ def test_sync_draft_workflow_hash_mismatch(app: Flask, monkeypatch: pytest.Monke
handler(api, "t1", app_model=SimpleNamespace(id="app"))
def test_sync_draft_workflow_variable_validation_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def _raise(*_args, **_kwargs):
raise workflow_module.VariableError("description too long")
monkeypatch.setattr(workflow_module.variable_factory, "build_conversation_variable_from_mapping", _raise)
monkeypatch.setattr(
workflow_module, "WorkflowService", lambda: SimpleNamespace(sync_draft_workflow=lambda **_kwargs: None)
)
api = workflow_module.DraftWorkflowApi()
handler = inspect.unwrap(api.post)
with app.test_request_context(
"/apps/app/workflows/draft",
method="POST",
json={"graph": {}, "features": {}, "hash": "h", "conversation_variables": [{"name": "topic"}]},
):
with pytest.raises(InvalidArgumentError) as exc:
handler(api, "t1", app_model=SimpleNamespace(id="app"))
assert exc.value.description == "description too long"
def test_restore_published_workflow_to_draft_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
workflow = SimpleNamespace(
unique_hash="restored-hash",

View File

@ -8,8 +8,8 @@ from unittest.mock import Mock
import pytest
from flask import Flask
from controllers.common.errors import NotFoundError
from controllers.console.app import workflow_run as workflow_run_module
from controllers.web.error import NotFoundError
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig

View File

@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, Response
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.console import console_ns
from controllers.console.app.error import DraftWorkflowNotExist
from controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable import (
@ -13,7 +14,6 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable impor
RagPipelineVariableCollectionApi,
RagPipelineVariableResetApi,
)
from controllers.web.error import InvalidArgumentError, NotFoundError
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
from graphon.variables.types import SegmentType
from models.account import Account, TenantAccountRole

View File

@ -9,6 +9,7 @@ from unittest.mock import Mock
import pytest
from flask import Flask, Response
from controllers.common.errors import NotFoundError
from controllers.common.human_input import HumanInputFormSubmitPayload
from controllers.console.human_input_form import (
ConsoleHumanInputFormApi,
@ -17,7 +18,6 @@ from controllers.console.human_input_form import (
WorkflowResponseConverter,
_jsonify_form_definition,
)
from controllers.web.error import NotFoundError
from models.account import AccountStatus
from models.enums import CreatorUserRole
from models.human_input import RecipientType

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import pytest
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.web.error import (
AppMoreLikeThisDisabledError,
AppSuggestedQuestionsAfterAnswerDisabledError,
@ -11,12 +12,10 @@ from controllers.web.error import (
AudioTooLargeError,
CompletionRequestError,
ConversationCompletedError,
InvalidArgumentError,
InvokeRateLimitError,
NoAudioUploadedError,
NotChatAppError,
NotCompletionAppError,
NotFoundError,
NotWorkflowAppError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,

View File

@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.web.error import NotFoundError
from controllers.common.errors import NotFoundError
from controllers.web.workflow_events import WorkflowEventsApi
from models.enums import CreatorUserRole

View File

@ -200,6 +200,19 @@ def test_variable_cannot_large_than_200_kb():
)
def test_conversation_variable_description_cannot_exceed_255_chars():
with pytest.raises(VariableError, match="description of variable 'test_text' is too long"):
variable_factory.build_conversation_variable_from_mapping(
{
"id": str(uuid4()),
"value_type": "string",
"name": "test_text",
"value": "value",
"description": "a" * 256,
}
)
def test_array_none_variable():
var = variable_factory.build_segment([None, None, None, None])
assert isinstance(var, ArrayAnySegment)

View File

@ -31,6 +31,7 @@ from services.workflow_draft_variable_service import (
DraftVariableSaver,
VariableResetError,
WorkflowDraftVariableService,
_model_to_insertion_dict,
)
@ -643,3 +644,53 @@ class TestWorkflowDraftVariableService:
assert node_var.visible == True
assert node_var.editable == True
assert node_var.node_execution_id == "exec-id"
class TestModelToInsertionDict:
"""Reproduce two production errors in _model_to_insertion_dict / _new()."""
def test_visible_and_is_default_value_always_present(self):
"""Problem 1: _new() did not set visible/is_default_value, causing
inconsistent dict keys across rows in multi-row INSERT and missing
is_default_value in the insertion dict entirely.
"""
conv_var = WorkflowDraftVariable.new_conversation_variable(
app_id="app-1",
name="counter",
value=StringSegment(value="0"),
)
# _new() should explicitly set these fields so they are not None
assert conv_var.visible is not None
assert conv_var.is_default_value is not None
d = _model_to_insertion_dict(conv_var)
# visible must appear in every row's dict
assert "visible" in d
# is_default_value must always be present
assert "is_default_value" in d
def test_description_passthrough(self):
"""_model_to_insertion_dict passes description as-is;
length validation is enforced earlier in build_conversation_variable_from_mapping.
"""
desc = "a" * 200
conv_var = WorkflowDraftVariable.new_conversation_variable(
app_id="app-1",
name="counter",
value=StringSegment(value="0"),
description=desc,
)
d = _model_to_insertion_dict(conv_var)
assert d["description"] == desc
def test_is_default_value_omitted_when_none(self):
conv_var = WorkflowDraftVariable.new_conversation_variable(
app_id="app-1",
name="counter",
value=StringSegment(value="0"),
)
conv_var.is_default_value = None
d = _model_to_insertion_dict(conv_var)
assert "is_default_value" not in d

View File

@ -195,6 +195,34 @@ describe('useVariableModalState', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('should notify and stop saving when description exceeds the limit', () => {
const notify = vi.fn()
const onSave = vi.fn()
const onClose = vi.fn()
const { result } = renderHook(() => useVariableModalState(createOptions({
notify,
onClose,
onSave,
})))
act(() => {
result.current.handleVarNameChange({ target: { value: 'greeting' } } as ChangeEvent<HTMLInputElement>)
result.current.handleStringOrNumberChange(['hello'])
result.current.setDescription('a'.repeat(256))
})
act(() => {
result.current.handleSave()
})
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'chatVariable.modal.descriptionTooLong',
})
expect(onSave).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('should save a new variable and close when state is valid', () => {
const onSave = vi.fn()
const onClose = vi.fn()

View File

@ -24,6 +24,7 @@ describe('variable-modal sections', () => {
/>
<DescriptionSection
description="original description"
maxLength={255}
onChange={onDescriptionChange}
placeholder="description-placeholder"
title="Description"
@ -43,6 +44,20 @@ describe('variable-modal sections', () => {
expect(onDescriptionChange).toHaveBeenCalledWith('updated-description')
})
it('should show description length against the configured limit', () => {
render(
<DescriptionSection
description="abc"
maxLength={255}
onChange={vi.fn()}
placeholder="description-placeholder"
title="Description"
/>,
)
expect(screen.getByText('3/255')).toBeInTheDocument()
})
it('renders type and value sections and forwards toggle and value changes', async () => {
const onSelect = vi.fn()
const onArrayChange = vi.fn()

View File

@ -11,6 +11,7 @@ import {
getEditorMinHeight,
getPlaceholderByType,
getTypeChangeState,
MAX_DESCRIPTION_LENGTH,
parseEditorContent,
validateVariableName,
} from './variable-modal.helpers'
@ -196,6 +197,17 @@ export const useVariableModalState = ({
return
}
if (state.description.length > MAX_DESCRIPTION_LENGTH) {
notify({
type: 'error',
message: t('chatVariable.modal.descriptionTooLong', {
maxLength: MAX_DESCRIPTION_LENGTH,
ns: 'workflow',
}),
})
return
}
onSave({
description: state.description,
id: chatVar ? chatVar.id : uuid4(),

View File

@ -29,6 +29,8 @@ export type ToastPayload = {
customComponent?: ReactNode
}
export const MAX_DESCRIPTION_LENGTH = 255
export const typeList = [
ChatVarTypeEnum.String,
ChatVarTypeEnum.Number,

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import type { ObjectValueItem } from './variable-modal.helpers'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiDraftLine, RiInputField } from '@remixicon/react'
import Input from '@/app/components/base/input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
@ -195,6 +196,7 @@ export const ValueSection = ({
type DescriptionSectionProps = {
description: string
maxLength: number
onChange: (value: string) => void
placeholder: string
title: string
@ -202,6 +204,7 @@ type DescriptionSectionProps = {
export const DescriptionSection = ({
description,
maxLength,
onChange,
placeholder,
title,
@ -216,5 +219,10 @@ export const DescriptionSection = ({
onChange={e => onChange(e.target.value)}
/>
</div>
<div className={cn('mt-1 text-right system-xs-regular', description.length > maxLength ? 'text-text-destructive' : 'text-text-quaternary')}>
{description.length}
/
{maxLength}
</div>
</div>
)

View File

@ -12,6 +12,7 @@ import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
import { useVariableModalState } from './use-variable-modal-state'
import {
getEditorToggleLabelKey,
MAX_DESCRIPTION_LENGTH,
typeList,
validateVariableName,
} from './variable-modal.helpers'
@ -72,6 +73,7 @@ const ChatVariableModal = ({
return
handleVarNameChange(e)
}
return (
<div
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
@ -127,6 +129,7 @@ const ChatVariableModal = ({
/>
<DescriptionSection
description={description}
maxLength={MAX_DESCRIPTION_LENGTH}
onChange={setDescription}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
title={t('chatVariable.modal.description', { ns: 'workflow' })}

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Value",
"chatVariable.modal.description": "Description",
"chatVariable.modal.descriptionPlaceholder": "Describe the variable",
"chatVariable.modal.descriptionTooLong": "Description must be {{maxLength}} characters or less",
"chatVariable.modal.editInForm": "Edit in Form",
"chatVariable.modal.editInJSON": "Edit in JSON",
"chatVariable.modal.editTitle": "Edit Conversation Variable",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "值",
"chatVariable.modal.description": "描述",
"chatVariable.modal.descriptionPlaceholder": "变量的描述",
"chatVariable.modal.descriptionTooLong": "描述不能超过 {{maxLength}} 个字符",
"chatVariable.modal.editInForm": "在表单中编辑",
"chatVariable.modal.editInJSON": "在 JSON 中编辑",
"chatVariable.modal.editTitle": "编辑会话变量",