fix: validate memory provider names before filesystem lookup and setup commands

Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.
This commit is contained in:
teknium1 2026-07-07 17:11:29 -07:00 committed by Teknium
parent 4b184cbe54
commit d9a4b5a5e5
2 changed files with 41 additions and 0 deletions

View file

@ -4895,8 +4895,24 @@ def _write_memory_provider_config_values(
save_env_value(env_key, secret)
_MEMORY_PROVIDER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
def _require_valid_memory_provider_name(name: str) -> None:
"""Reject provider names that could traverse outside the plugin dirs.
``name`` is interpolated into filesystem paths by ``find_provider_dir()``
and gates which plugin manifest's setup commands run. A strict charset
allowlist (no path separators, no dots) makes traversal impossible
regardless of how the downstream lookup evolves.
"""
if not _MEMORY_PROVIDER_NAME_RE.fullmatch(name or ""):
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
_require_valid_memory_provider_name(name)
provider = _load_memory_provider(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
@ -4907,7 +4923,14 @@ async def get_memory_provider_config(name: str):
@app.post("/api/memory/providers/{name}/setup")
async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
_require_valid_memory_provider_name(name)
provider = _load_memory_provider(name)
if provider is None and not _memory_provider_manifest(name):
# No discoverable plugin directory → nothing whose manifest could
# legitimately declare setup commands. Refuse before the
# command-running path. (provider may be None with a manifest present
# when its pip deps aren't installed yet — that's the setup use case.)
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
if provider is not None and body.values:
try:
_write_memory_provider_config_values(name, provider, body.values)
@ -4921,6 +4944,7 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
_require_valid_memory_provider_name(name)
provider = _load_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")

View file

@ -522,6 +522,23 @@ class TestWebServerEndpoints:
assert resp.status_code == 404
def test_memory_provider_endpoints_reject_traversal_names(self):
# Names with path separators / dots must never reach the filesystem
# lookup or the setup command path. 404 = rejected by the name guard;
# 405 = the router collapsed the dotted path onto a different route
# (equally safe — the handler never ran).
for bad in ("..", "..%2f..%2fetc", "a.b", "x/y", ".hidden", ""):
resp = self.client.get(f"/api/memory/providers/{bad}/config")
assert resp.status_code in (404, 405), (bad, resp.status_code)
resp = self.client.post(
f"/api/memory/providers/{bad}/setup", json={"values": {}}
)
assert resp.status_code in (404, 405), (bad, resp.status_code)
resp = self.client.put(
f"/api/memory/providers/{bad}/config", json={"values": {}}
)
assert resp.status_code in (404, 405), (bad, resp.status_code)
def test_post_memory_provider_setup_persists_values_without_activation(self):
from hermes_cli.config import load_config, load_env