Repository navigation
fix(agent-memory): encode memory and message identifiers in URL paths - #454
Conversation
Signed-off-by: João Pedro Brun <joao.pedro.brun@sap.com>
Code ReviewReviewed against the 23-criterion Still open
Clean / verified
Things done well
|
tiagoek
left a comment
There was a problem hiding this comment.
PR Review: #454 — fix(agent-memory): encode memory and message identifiers in URL paths
Author: jplbrun Branch: fix/hasi-agent-memory-url-injection → main
Verdict:
Summary: Security fix is correct, well-tested, and well-documented; two minor housekeeping items before merge.
A: Process & Compliance
| # | Criterion | Status | Finding |
|---|---|---|---|
| A1 | PR template complete | ✅ Pass | All checklist items ticked or explicitly marked N/A; security-review checkbox correctly ticked and review provided in Additional Notes |
| A2 | Conventional Commits | ✅ Pass | fix(agent-memory): … — valid; Commit Validation passes |
| A3 | Issue linked | No Closes #N — body marks it N/A; non-blocking per review rules |
|
| A4 | AI-generated code disclosure | ➖ N/A | No evidence of generated code |
B: Security & Sensitive Data
| # | Criterion | Status | Finding |
|---|---|---|---|
| B1 | No sensitive data in code | ✅ Pass | No credentials or internal URLs; .invalid TLD used throughout examples |
| B2 | No sensitive data in PR body | ✅ Pass | Demo URL uses fictional domain memory.example.invalid |
C: Code Quality
| # | Criterion | Status | Finding |
|---|---|---|---|
| C1 | CI checks passing | ✅ Pass | All 12 required jobs green, including Integration Tests |
| C2 | Version bump | ➖ N/A | pyproject.toml untouched |
| C3 | Type hints | ✅ Pass | _encode_entity_id(value: object) -> str fully annotated; existing method signatures unchanged |
| C4 | No hardcoded values | ✅ Pass | safe="" is a correct stdlib parameter, not a magic value |
| C5 | Import organization | ✅ Pass | quote added to the existing top-level from urllib.parse import … line |
| C6 | Naming conventions | ✅ Pass | _encode_entity_id — private, snake_case; test file test_url_identifiers.py — snake_case |
| C7 | No unused code | ✅ Pass | All five call sites use _encode_entity_id; quote import was added |
| C8 | No unjustified new dependencies | ✅ Pass | quote is stdlib (urllib.parse) |
| C9 | Proto code freshness | ➖ N/A | No proto changes |
D: API & Design
| # | Criterion | Status | Finding |
|---|---|---|---|
| D1 | API future-proofing | ✅ Pass | Helper is private; public signatures unchanged |
| D2 | Public API hygiene | ✅ Pass | _encode_entity_id has underscore prefix; no new public symbols |
| D3 | Breaking changes marked | ✅ Pass | PR explicitly documents behavior changes for non-UUID inputs in the Breaking Changes section |
| D4 | Pagination & tenant filtering | ➖ N/A | No changes to list/query operations |
| D5 | Telemetry instrumentation | ✅ Pass | All five modified methods retain their @record_metrics decorators |
| D6 | Multi-tenancy support | ➖ N/A | Tenant routing is upstream of the encoding fix; no multi-tenancy changes |
E: Tests & Documentation
| # | Criterion | Status | Finding |
|---|---|---|---|
| E1 | Tests added/updated | ✅ Pass | New test_url_identifiers.py covers all 5 call sites via @pytest.mark.parametrize, path injection, pre-encoded double-encoding, UUID spelling preservation, .. inside OData key, and the surrogate-char encoding failure path |
| E2 | Documentation quality | The 5 changed methods document AgentMemoryHttpError: If the request fails. — after this fix the error can also be raised for unencodable IDs before any HTTP call. A "or if the identifier cannot be encoded" clause would make the contract complete. Affected methods: get_memory (line 253), update_memory (line 278), delete_memory (line 308), get_message (line 512), delete_message (line 530) |
|
| E2a | Multi-tenancy documentation | ➖ N/A | No user-guide.md modified |
| E3 | Module structure compliance | ➖ N/A | No new module |
❌ Blocking Issues
None.
⚠️ Non-Blocking Suggestions
-
[A3] No linked issue in the PR body — non-blocking but a public issue reference would help traceability if this tracks an internal finding or CVE.
-
[E2]
client.pylines 253, 278, 308, 512, 530 — Each of the five changed methods documentsAgentMemoryHttpError: If the request fails.in itsRaises:section. After this fix, that error can also be raised whenmemory_idormessage_idcontains lone surrogates (unencodable characters), before any HTTP request is made. The docstrings should be extended: e.g.,AgentMemoryHttpError: If the request fails, or if the identifier cannot be encoded as UTF-8.
✅ Things Done Well
- Minimal, surgical fix. One 10-line private helper and five one-line substitutions close the path-traversal and query/fragment-injection vectors in all affected call sites with no collateral changes.
- Correct
./..decision. The fix deliberately encodes/but leaves.unencoded. Because the ID sits inside OData key syntaxmemories(..), a literal..cannot climb the path — this diverges correctly from the sibling Print fix (#453) which rejects dots entirely. The reasoning is documented and thetest_dot_values_remain_inside_odata_keystest pins the intent. - Surrogate guard is explicit. The
\ud800test proves that lone surrogates raiseAgentMemoryHttpError(via__cause__ = UnicodeEncodeError) before any network call, and thathttp.requestis never invoked — exactly the right behavior. - Tests drive the real URL-building path.
test_prepared_urls_contain_ids_without_changing_odata_syntaxuses a realSessionand a mockedSession.send, so it asserts on the actual prepared OData URL rather than on a mock argument. - Pre-encoded input test.
"%29%2F%2e%2e%2Fother"→"%2529%252F%252e%252e%252Fother"confirms that no special treatment of percent signs is applied, closing the double-encoding ambiguity.
Signed-off-by: João Pedro Brun <joao.pedro.brun@sap.com>
addressed |
Description
Caller-supplied memory and message IDs were inserted directly into URL paths, allowing reserved characters to change the request URL. Encode
memory_idandmessage_idwithquote(..., safe="")in memory retrieval, update and deletion, and message retrieval and deletion. Preserve the surroundingmemories(<id>)andmessages(<id>)syntax, identifier spelling and request bodies. Encoding failures raise the existingAgentMemoryHttpError.Related Issue
N/A
Type of Change
How to Test
Run from the repository root after checking out this branch.
Run the Agent Memory unit suite:
Expected: 252 passed, including 13 regression cases covering all five URL call sites, pre-encoded inputs, UUID spelling preservation, dot values and encoding failures.
Compare the old URL construction with the updated client. This example prepares requests with
Session.sendmocked; it sends no network requests:Expected:
Before,
..changes the path,injected=yesbecomes a query, andfragment)becomes a URL fragment. Fragments are not sent in the HTTP request target. After, those characters remain encoded inside the memory identifier.Run the repository checks:
Expected: formatting, lint, whitespace and type checks pass. Unchanged YAML and lock-file hooks are skipped.
Checklist
/security-reviewand addressed all ❌ findingsBreaking Changes
No breaking changes for documented Agent Memory usage. Public client signatures, request bodies, and URLs for valid memory and message UUIDs are unchanged. These callers need no migration. UUID validation and normalization are not added.
This security fix changes how other identifier inputs behave:
),/,?,#, and%are encoded as identifier data instead of being interpreted as URL structure.AgentMemoryHttpErrorbefore an HTTP request.Literal
.and..values remain allowed because they stay insidememories(...)ormessages(...), rather than forming standalone path segments.These behavior changes affect inputs outside the service's documented UUID format; identical behavior for every previously accepted input is not claimed.
Additional Notes
Security Review
Reviewed against the 12-criterion
security-reviewcontract. Verdict: ✅ No security issues — nothing blocking and no open warnings._encode_entity_id(client.py:91) usesquote(segment, safe=""), applied at all five identifier call sites (get_memory,update_memory,delete_memory,get_message,delete_message). URL delimiters () / ? # %) are encoded as identifier data, so neithermemory_idnormessage_idcan alter the request URL structure. Path traversal and query/fragment injection are both closed../..intentionally allowed) Unlike the sibling Print fix (fix(print): encode queue and task identifiers in URL paths #453), this PR deliberately permits literal.and.., and that is correct here. The ids are wrapped in OData key syntax —f"{MEMORIES}({_encode_entity_id(id)})"withMEMORIES = "/v1/memories"— so the value sits insidememories(...)rather than forming a standalone path segment./v1/memories(..)cannot climb the path, and once encoded any/becomes%2Fregardless.test_dot_values_remain_inside_odata_keyspins this intended behavior.__init__already calls_validate_tenant_subdomain(tenant)(client.py:132) and only setsself._tenantfor the SUBSCRIBER access strategy. The new encoding sits downstream of that validated routing.AgentMemoryHttpError(client.py:99).AgentMemoryHttpErroris the error type callers already handle for these operations, so routing theUnicodeEncodeErrorthrough it preserves the public exception contract rather than leaking a rawUnicodeEncodeError; the original is kept as__cause__(asserted bytest_unencodable_ids_raise_the_existing_http_exception). A new exception type here would be the breaking change._request's debug log records method, path, and tenant — all operational identifiers), and noverify=False."\ud800"→UnicodeEncodeError→AgentMemoryHttpErrorbefore any HTTP call) handles an encoding edge case explicitly, and the regression suite drives the realHttpClient.requestURL-building path via a mockedSession.send, so it proves the prepared OData URL rather than asserting on a mock.