From 54f3c43fe3dadf8e12bb1bc2f79aea9ee62a3e11 Mon Sep 17 00:00:00 2001 From: Tianye Song <162393000+sontianye@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:54:45 +0800 Subject: [PATCH] fix(security): html-escape fact content in memory prompt sections (#4028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): html-escape fact content in memory prompt sections Raw memory fact content was injected verbatim into prompt XML — a fact containing a literal `"` could break the `"..."` delimiter, and a closing tag like `` could prematurely end the XML block, both potentially confusing the model. Apply `html.escape()` to `content` in `_build_staleness_section` and `_build_consolidation_section`, and to `cat` in the consolidation section's XML attribute. Tests added for both sections covering special characters, XML tag injection, and attribute injection. Follow-up to #3996 as noted by reviewer willem-bd. * fix(security): address reviewer follow-ups on html-escaping PR - Escape `cat` in _build_staleness_section for symmetry with the consolidation section (both sections now consistently html-escape all LLM-derived category values that appear in the prompt) - Add comment at current_memory=json.dumps() documenting the conscious accept: json.dumps leaves < > & unescaped; lower-risk than staleness/consolidation (read-only context, not delete/merge instructions); fix at fact-content insert time if revisited - Add test for category escaping in the staleness section * fix(security): reference tracking issue #4044 in conscious-accept comment * style: compress conscious-accept comment to two lines --- .../harness/deerflow/agents/memory/updater.py | 11 ++++-- backend/tests/test_memory_consolidation.py | 38 +++++++++++++++++++ backend/tests/test_memory_staleness_review.py | 32 ++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 9d99344c8..695c7cc6d 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -4,6 +4,7 @@ import asyncio import atexit import concurrent.futures import copy +import html import json import logging import math @@ -515,11 +516,11 @@ def _build_staleness_section( lines: list[str] = [] for fact in stale_candidates: fid = fact.get("id", "?") - cat = str(fact.get("category", "context")).strip() or "context" + cat = html.escape(str(fact.get("category", "context")).strip() or "context") conf = fact.get("confidence", 0.0) created_raw = fact.get("createdAt", "") created_short = created_raw[:10] if isinstance(created_raw, str) and len(created_raw) >= 10 else created_raw - content = str(fact.get("content", "")) + content = html.escape(str(fact.get("content", ""))) lines.append(f'- [{fid} | {cat} | {conf:.2f} | {created_short}] "{content}"') return STALENESS_REVIEW_PROMPT.format( stale_facts="\n".join(lines), @@ -575,10 +576,10 @@ def _build_consolidation_section( for fact in group[:max_sources]: fid = fact.get("id", "?") conf = _coerce_source_confidence(fact) - content = str(fact.get("content", "")) + content = html.escape(str(fact.get("content", ""))) lines.append(f'- [{fid} | {conf:.2f}] "{content}"') shown = min(len(group), max_sources) - parts.append(f'\n' + "\n".join(lines) + "\n") + parts.append(f'\n' + "\n".join(lines) + "\n") return CONSOLIDATION_PROMPT.format(consolidation_groups="\n\n".join(parts), max_groups=max_groups) @@ -671,6 +672,8 @@ class MemoryUpdater: max_sources=config.consolidation_max_sources, ) + # conscious accept: json.dumps leaves < > & unescaped in fact content (tracked in #4044); + # lower-risk than staleness/consolidation — read-only context, not delete/merge instructions. prompt = MEMORY_UPDATE_PROMPT.format( current_memory=json.dumps(current_memory, indent=2, ensure_ascii=False), conversation=conversation_text, diff --git a/backend/tests/test_memory_consolidation.py b/backend/tests/test_memory_consolidation.py index c7d422576..bd04ebbbf 100644 --- a/backend/tests/test_memory_consolidation.py +++ b/backend/tests/test_memory_consolidation.py @@ -156,6 +156,44 @@ class TestBuildConsolidationSection: assert 'category="preference"' in section assert "Memory Consolidation" in section + def test_html_special_chars_in_content_are_escaped(self): + """Fact content with XML tags or quotes is HTML-escaped so it cannot + break the surrounding prompt structure.""" + candidates = { + "knowledge": [ + _make_fact("fact_x", 'Like bold & "quotes"', "knowledge", 0.9), + _make_fact("fact_y", "normal content", "knowledge", 0.8), + ], + } + section = _build_consolidation_section(candidates) + assert "" not in section + assert "<b>" in section + assert "&" in section + assert """ in section + + def test_closing_tag_in_content_is_escaped(self): + """A closing tag in content must not + prematurely end the prompt XML block.""" + candidates = { + "knowledge": [ + _make_fact("fact_a", "injected", "knowledge", 0.9), + _make_fact("fact_b", "normal", "knowledge", 0.8), + ], + } + section = _build_consolidation_section(candidates) + assert "" not in section + assert "</consolidation_candidates>" in section + + def test_special_chars_in_category_attribute_are_escaped(self): + """A category name with a quote character must not break the XML + attribute value in the prompt.""" + candidates = { + 'pref"erences': [_make_fact(f"f_{i}", category='pref"erences') for i in range(3)], + } + section = _build_consolidation_section(candidates) + assert 'category="pref"erences"' not in section + assert "pref"erences" in section + # ── _normalize_memory_update_data with factsToConsolidate ───────────────── diff --git a/backend/tests/test_memory_staleness_review.py b/backend/tests/test_memory_staleness_review.py index 7dca2ce09..186850002 100644 --- a/backend/tests/test_memory_staleness_review.py +++ b/backend/tests/test_memory_staleness_review.py @@ -212,6 +212,38 @@ class TestBuildStalenessSection: assert "fact_b" in section assert "" in section + def test_html_special_chars_in_content_are_escaped(self): + """Fact content with XML tags or quotes is HTML-escaped so it cannot + break the surrounding prompt structure.""" + candidates = [ + _make_fact("fact_x", 'Like bold & "quotes"', "knowledge", 0.9, days_ago=100), + ] + section = _build_staleness_section(candidates, 90) + assert "" not in section + assert "<b>" in section + assert "&" in section + assert """ in section + + def test_closing_tag_in_content_is_escaped(self): + """A closing tag embedded in content must not prematurely + end the prompt XML block.""" + candidates = [ + _make_fact("fact_y", "bad", "knowledge", 0.8, days_ago=100), + ] + section = _build_staleness_section(candidates, 90) + assert "" not in section + assert "</stale_facts>" in section + + def test_special_chars_in_category_are_escaped(self): + """A category name with XML tags or quotes is HTML-escaped, consistent + with how category is handled in the consolidation section.""" + candidates = [ + _make_fact("fact_z", "content", 'pref<"erences>', 0.8, days_ago=100), + ] + section = _build_staleness_section(candidates, 90) + assert 'pref<"erences>' not in section + assert "pref<"erences>" in section + # ── _apply_updates with staleness removals ─────────────────────────────────