mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-07 03:48:44 +08:00
## Summary While fixing #16467 (IterationItem crash on `@` in user-defined output keys), an audit of `agent/**/*.py` revealed **three additional sites** with the same vulnerability. This PR hardens all of them with `maxsplit=1` and adds regression tests. This is **defense-in-depth hardening**, not a behavior change. The current `variable_ref_patt` regex constrains `var_nm` to `[A-Za-z0-9_.-]+`, so single-`@` templates resolve exactly as before. The `maxsplit=1` only kicks in if the trailing side itself contains `@` — currently unreachable from the public DSL surface, but trivially exploitable the moment a user-defined output key happens to contain `@` (e.g. `user@email`) or the regex is ever relaxed. > **Note on issue scope**: The primary fix for #16467 (the `list_tenant_added_models` `ValueError` crash on `@` in model names) is in PR #16468. This PR is a **follow-up hardening sweep** of the same vulnerability class found in `agent/` during that audit; it does not duplicate or replace #16468. ## Sites hardened | File | Line | Method | |------|------|--------| | `agent/canvas.py` | 206 | `Graph.get_variable_value` | | `agent/canvas.py` | 256 | `Graph.set_variable_value` | | `agent/component/base.py` | 533 | `ComponentBase.get_input_elements_from_text` | | `agent/component/iterationitem.py` | 88 | `IterationItem.output_collation` | All now use `split("@", 1)` with an inline comment explaining the rationale. The trailing side keeps any embedded `@`. ## Sites already safe (audited but left alone) | File | Reason safe | |------|------------| | `agent/canvas.py:708` (`is_reff`) | Pre-checks `len(arr) != 2` | | `agent/component/categorize.py` | Uses `rsplit` | | `agent/component/iteration.py` | Pre-validates via regex | | Other call sites | `rsplit` or regex pre-validation | ## Regression tests 9 new tests across 2 files, all `pytest.mark.p2`: | File | Tests | |------|-------| | `test/unit_test/agent/test_canvas_at_split.py` | 6 — `get_variable_value`, `set_variable_value`, round-trip, single-`@`, missing-component | | `test/unit_test/agent/component/test_iterationitem_at_split.py` | 3 — `output_collation` with `@` in var, single-`@`, non-matching cid | Each test was **verified to fail with `ValueError: too many values to unpack (expected 2)`** when the corresponding fix is temporarily reverted, confirming the tests actually catch the bug rather than just exercising the happy path. ## Test results ``` 9 passed in 0.04s ``` Full agent unit suite also clean (38 passed, 3 skipped; 6 unrelated pre-existing collection errors from missing `peewee`/`requests` in local venv — not caused by this PR). ## Related - Issue: #16467 - Primary fix PR: #16468 (closes the issue) - This PR: defense-in-depth follow-up, intentionally non-blocking on #16467 --------- Co-authored-by: skbs-eng <skbs-eng@users.noreply.github.com>
354 lines
13 KiB
Python
354 lines
13 KiB
Python
#
|
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
"""Hardening regression test for `@`-splitting in
|
|
`agent/component/iterationitem.py:82` (inside `output_collation`).
|
|
|
|
Before the fix, `o["ref"].split("@")` raised `ValueError: too many values
|
|
to unpack` whenever a user-defined output key contained `@` (e.g. an
|
|
output named `foo@bar` referenced from another component). After the
|
|
fix, the call uses `split("@", 1)`, preserving the trailing `@`-bearing
|
|
key in `var` so the collation append can proceed normally.
|
|
|
|
The module is loaded via `importlib` after stubbing its heavyweight
|
|
imports — same isolation strategy as
|
|
`test/testcases/test_web_api/test_canvas_app/test_iterationitem_unit.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
# ─── Module loader ────────────────────────────────────────────────────
|
|
|
|
|
|
def _load_iterationitem_module(monkeypatch):
|
|
"""Load `agent.component.iterationitem` in isolation with stubs.
|
|
|
|
Unlike `test_canvas_at_split.py`, the iterationitem module's import
|
|
graph is small enough that we can run the real `common.*` modules
|
|
(after stubbing their `quart` dependency). That gives us high
|
|
fidelity on the `_param.outputs` iteration logic without dragging
|
|
in the canvas-level graph (which the tests don't need).
|
|
|
|
Returns:
|
|
The loaded `agent.component.iterationitem` module object.
|
|
"""
|
|
repo_root = Path(__file__).resolve().parents[4]
|
|
|
|
# Stub `quart` because `common.connection_utils` imports from it.
|
|
quart_stub = ModuleType("quart")
|
|
quart_stub.make_response = MagicMock()
|
|
quart_stub.jsonify = MagicMock()
|
|
monkeypatch.setitem(sys.modules, "quart", quart_stub)
|
|
|
|
# Stub the heavy transitive imports so we never touch pandas/jinja2/etc.
|
|
common_pkg = ModuleType("common")
|
|
common_pkg.__path__ = [str(repo_root / "common")]
|
|
monkeypatch.setitem(sys.modules, "common", common_pkg)
|
|
|
|
conn_spec = importlib.util.spec_from_file_location("common.connection_utils", repo_root / "common" / "connection_utils.py")
|
|
conn_mod = importlib.util.module_from_spec(conn_spec)
|
|
monkeypatch.setitem(sys.modules, "common.connection_utils", conn_mod)
|
|
conn_spec.loader.exec_module(conn_mod)
|
|
|
|
misc_spec = importlib.util.spec_from_file_location("common.misc_utils", repo_root / "common" / "misc_utils.py")
|
|
misc_mod = importlib.util.module_from_spec(misc_spec)
|
|
monkeypatch.setitem(sys.modules, "common.misc_utils", misc_mod)
|
|
misc_spec.loader.exec_module(misc_mod)
|
|
|
|
constants_mod = ModuleType("common.constants")
|
|
|
|
class _RetCode:
|
|
"""Minimal stand-in for `common.constants.RetCode`.
|
|
|
|
Only the two integer sentinels actually referenced by
|
|
`iterationitem.py` are defined; everything else would otherwise
|
|
require loading the entire constants module.
|
|
"""
|
|
|
|
SUCCESS = 0
|
|
EXCEPTION_ERROR = 100
|
|
|
|
constants_mod.RetCode = _RetCode
|
|
monkeypatch.setitem(sys.modules, "common.constants", constants_mod)
|
|
|
|
agent_pkg = ModuleType("agent")
|
|
agent_pkg.__path__ = [str(repo_root / "agent")]
|
|
monkeypatch.setitem(sys.modules, "agent", agent_pkg)
|
|
|
|
agent_settings = ModuleType("agent.settings")
|
|
agent_settings.FLOAT_ZERO = 1e-8
|
|
agent_settings.PARAM_MAXDEPTH = 5
|
|
monkeypatch.setitem(sys.modules, "agent.settings", agent_settings)
|
|
|
|
component_pkg = ModuleType("agent.component")
|
|
component_pkg.__path__ = [str(repo_root / "agent" / "component")]
|
|
monkeypatch.setitem(sys.modules, "agent.component", component_pkg)
|
|
|
|
# Provide a minimal `ComponentBase` for the iterationitem module to
|
|
# inherit from. Only the surface used by `output_collation` matters:
|
|
# `output(var_nm)`, `set_output(key, value)`, plus the parent lookup
|
|
# `get_parent()` is defined on the test instance directly.
|
|
class _ComponentBaseStub:
|
|
"""No-op stand-in for the real `ComponentBase`.
|
|
|
|
`iterationitem.IterationItem` extends this class, so any
|
|
instantiation path that isn't bypassed via `__new__` would
|
|
require it to be importable. We deliberately no-op every
|
|
method so that nothing incidental fires during loading.
|
|
"""
|
|
|
|
def __init__(self, *a, **kw):
|
|
"""Accept and discard all args; the stub is purely nominal."""
|
|
pass
|
|
|
|
def output(self, var_nm=None):
|
|
"""Return an empty string for any variable lookup."""
|
|
return ""
|
|
|
|
def set_output(self, key, value):
|
|
"""Discard writes; iterationitem test paths verify via
|
|
`parent._param.outputs`, not via the base class."""
|
|
pass
|
|
|
|
class _ComponentParamBaseStub:
|
|
"""Stand-in for `ComponentParamBase` with mutable `outputs`/`inputs`."""
|
|
|
|
def __init__(self):
|
|
"""Initialize empty `outputs` and `inputs` dicts."""
|
|
self.outputs = {}
|
|
self.inputs = {}
|
|
|
|
def check(self):
|
|
"""Always succeed; validation is not exercised in these tests."""
|
|
return True
|
|
|
|
base_mod = ModuleType("agent.component.base")
|
|
base_mod.ComponentBase = _ComponentBaseStub
|
|
base_mod.ComponentParamBase = _ComponentParamBaseStub
|
|
monkeypatch.setitem(sys.modules, "agent.component.base", base_mod)
|
|
|
|
iterationitem_spec = importlib.util.spec_from_file_location(
|
|
"agent.component.iterationitem",
|
|
repo_root / "agent" / "component" / "iterationitem.py",
|
|
)
|
|
iterationitem_mod = importlib.util.module_from_spec(iterationitem_spec)
|
|
monkeypatch.setitem(sys.modules, "agent.component.iterationitem", iterationitem_mod)
|
|
iterationitem_spec.loader.exec_module(iterationitem_mod)
|
|
return iterationitem_mod
|
|
|
|
|
|
# ─── Fixtures ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class _ParentStub:
|
|
"""Parent iteration component.
|
|
|
|
`_param.outputs` is keyed by output name; each entry has a `"ref"`
|
|
pointing to a child component (`"<cid>@<var_name>"`).
|
|
"""
|
|
|
|
def __init__(self, _id, outputs, component_name="Iteration"):
|
|
"""Store `_id`, default each output's `"value"` to `[]`, expose
|
|
the canonical `outputs` dict on a `_param` SimpleNamespace."""
|
|
self._id = _id
|
|
self.component_name = component_name
|
|
# Normalize: every output entry must carry a `"value"` key so
|
|
# `p.output(k)` and the post-append `p.set_output(k, res)` work
|
|
# the same way they would on a real `ComponentBase`.
|
|
normalized = {}
|
|
for name, payload in outputs.items():
|
|
entry = dict(payload)
|
|
entry.setdefault("value", [])
|
|
normalized[name] = entry
|
|
self._param = SimpleNamespace(outputs=normalized)
|
|
|
|
def output(self, var_nm):
|
|
"""Return the `"value"` of the named output, or `""` if absent."""
|
|
return self._param.outputs.get(var_nm, {}).get("value", "")
|
|
|
|
def set_output(self, key, value):
|
|
"""Write `value` into `_param.outputs[key]["value"]`, creating
|
|
the entry with the appropriate `type` sentinel on first write."""
|
|
if key not in self._param.outputs:
|
|
self._param.outputs[key] = {"value": None, "type": str(type(value))}
|
|
self._param.outputs[key]["value"] = value
|
|
|
|
|
|
class _ChildStub:
|
|
"""Child component inside an iteration.
|
|
|
|
`_param.outputs` stores the child component's own outputs in the
|
|
`ComponentBase` `{"value": ...}` shape.
|
|
"""
|
|
|
|
def __init__(self, _id, parent, output_values):
|
|
"""Bind to a parent and seed `_param.outputs` from a flat dict.
|
|
|
|
Args:
|
|
_id: Child component id used as the `<cid>` half of refs.
|
|
parent: The parent iteration component (for `get_parent`).
|
|
output_values: `{output_name: value}` flat mapping, wrapped
|
|
into the `ComponentBase` `{"value": ..., "type": ...}`
|
|
shape before storage.
|
|
"""
|
|
self._id = _id
|
|
self._parent = parent
|
|
self.component_name = "Generate"
|
|
self._param = SimpleNamespace(outputs={k: {"value": v, "type": str(type(v))} for k, v in output_values.items()})
|
|
|
|
def get_parent(self):
|
|
"""Return the parent iteration component passed at construction."""
|
|
return self._parent
|
|
|
|
def output(self, var_nm):
|
|
"""Return the `"value"` of the named output, or `""` if absent."""
|
|
return self._param.outputs.get(var_nm, {}).get("value", "")
|
|
|
|
def set_output(self, key, value):
|
|
"""Write `value` into `_param.outputs[key]["value"]`, creating
|
|
the entry with the appropriate `type` sentinel on first write."""
|
|
if key not in self._param.outputs:
|
|
self._param.outputs[key] = {"value": None, "type": str(type(value))}
|
|
self._param.outputs[key]["value"] = value
|
|
|
|
|
|
class _CanvasStub:
|
|
"""Minimal canvas: only `components` (dict) and `get_component_obj`
|
|
are touched by `output_collation`."""
|
|
|
|
def __init__(self, components):
|
|
"""Store the `{cid: component}` lookup used by `get_component_obj`."""
|
|
self.components = components
|
|
|
|
def get_component_obj(self, cid):
|
|
"""Look up a component by id; the stub never returns `None`."""
|
|
return self.components[cid]
|
|
|
|
|
|
def _make_iteration_item(module, canvas, parent):
|
|
"""Construct an `IterationItem` bypassing its normal `__init__`.
|
|
|
|
`output_collation` only touches `self._canvas`, `self._id`, and
|
|
`self.get_parent()`, so a stripped-down instance is sufficient.
|
|
"""
|
|
inst = module.IterationItem.__new__(module.IterationItem)
|
|
inst._canvas = canvas
|
|
inst._id = "IterationItem:test"
|
|
inst._param = SimpleNamespace(outputs={}, inputs={})
|
|
inst._idx = 0
|
|
inst.get_parent = MagicMock(return_value=parent)
|
|
return inst
|
|
|
|
|
|
# ─── Tests ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_output_collation_accepts_at_in_var_name(monkeypatch):
|
|
"""The parent iteration's output `result` references the child's
|
|
output `foo@bar` (a user-defined key containing `@`). Before the
|
|
fix, `o["ref"].split("@")` raised `ValueError: too many values to
|
|
unpack`. After the fix, `output_collation` must:
|
|
1. Not raise.
|
|
2. Append the child's `foo@bar` value into the parent's `result`
|
|
list (i.e. treat `var = "foo@bar"` as a literal output name).
|
|
"""
|
|
module = _load_iterationitem_module(monkeypatch)
|
|
|
|
parent_outputs = {
|
|
"result": {"ref": "child-1@foo@bar"},
|
|
}
|
|
parent = _ParentStub("iter-pid", parent_outputs)
|
|
|
|
child = _ChildStub(
|
|
_id="child-1",
|
|
parent=parent,
|
|
output_values={"foo@bar": "value-A"},
|
|
)
|
|
|
|
canvas = _CanvasStub({"child-1": child})
|
|
|
|
item = _make_iteration_item(module, canvas, parent)
|
|
|
|
# Should NOT raise ValueError.
|
|
item.output_collation()
|
|
|
|
# The child's `foo@bar` value should have been appended to the
|
|
# parent's `result` list.
|
|
assert parent._param.outputs["result"]["value"] == ["value-A"]
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_output_collation_single_at_still_works(monkeypatch):
|
|
"""Sanity check: the existing single-`@` ref path is unaffected."""
|
|
module = _load_iterationitem_module(monkeypatch)
|
|
|
|
parent_outputs = {
|
|
"result": {"ref": "child-1@normal_var"},
|
|
}
|
|
parent = _ParentStub("iter-pid", parent_outputs)
|
|
|
|
child = _ChildStub(
|
|
_id="child-1",
|
|
parent=parent,
|
|
output_values={"normal_var": "value-B"},
|
|
)
|
|
|
|
canvas = _CanvasStub({"child-1": child})
|
|
|
|
item = _make_iteration_item(module, canvas, parent)
|
|
|
|
item.output_collation()
|
|
|
|
assert parent._param.outputs["result"]["value"] == ["value-B"]
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_output_collation_skips_non_matching_cid(monkeypatch):
|
|
"""When the ref's `cid` doesn't match the current `cid`, the loop
|
|
must skip silently even if `var` contains `@`. This guards against
|
|
an over-eager fix that might, e.g., re-split on the wrong side."""
|
|
module = _load_iterationitem_module(monkeypatch)
|
|
|
|
# The ref points at `other-cid`, not `child-1`.
|
|
parent_outputs = {
|
|
"result": {"ref": "other-cid@foo@bar"},
|
|
}
|
|
parent = _ParentStub("iter-pid", parent_outputs)
|
|
|
|
child = _ChildStub(
|
|
_id="child-1",
|
|
parent=parent,
|
|
output_values={"foo@bar": "ignored"},
|
|
)
|
|
|
|
canvas = _CanvasStub({"child-1": child})
|
|
|
|
item = _make_iteration_item(module, canvas, parent)
|
|
|
|
# Must not raise.
|
|
item.output_collation()
|
|
|
|
# Nothing should have been appended (the ref didn't match this cid).
|
|
assert parent._param.outputs["result"]["value"] == []
|