Skip to content

Commit 9dbe632

Browse files
zeevdrclaude
andcommitted
fix(watcher): sanitize tenant_id in asyncio task and thread names
Strip non-printable characters from tenant_id before embedding it in the asyncio task name (async_watcher) and threading.Thread name (watcher). A module-level compiled regex removes everything outside the printable ASCII range (0x20–0x7E), so user-controlled tenant IDs containing control characters no longer propagate into task/thread names. Closes #70 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 073a514 commit 9dbe632

4 files changed

Lines changed: 48 additions & 4 deletions

File tree

sdk/src/opendecree/async_watcher.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import asyncio
2222
import logging
2323
import random
24+
import re
2425
from collections.abc import AsyncIterator, Callable
2526
from typing import Any, TypeVar
2627

@@ -38,6 +39,8 @@
3839

3940
logger = logging.getLogger("opendecree.async_watcher")
4041

42+
_CONTROL_CHARS_RE = re.compile(r"[^\x20-\x7E]")
43+
4144
T = TypeVar("T")
4245

4346

@@ -152,9 +155,8 @@ async def start(self) -> None:
152155

153156
await self._load_snapshot()
154157
self._stopped = False
155-
self._task = asyncio.create_task(
156-
self._subscribe_loop(), name=f"decree-watcher-{self._tenant_id}"
157-
)
158+
safe_id = _CONTROL_CHARS_RE.sub("", self._tenant_id)
159+
self._task = asyncio.create_task(self._subscribe_loop(), name=f"decree-watcher-{safe_id}")
158160

159161
async def stop(self) -> None:
160162
"""Stop watching and cancel the background task."""

sdk/src/opendecree/watcher.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
import queue
2020
import random
21+
import re
2122
import threading
2223
import time
2324
from collections.abc import Callable, Iterator
@@ -37,6 +38,8 @@
3738

3839
logger = logging.getLogger("opendecree.watcher")
3940

41+
_CONTROL_CHARS_RE = re.compile(r"[^\x20-\x7E]")
42+
4043
T = TypeVar("T")
4144

4245

@@ -156,8 +159,9 @@ def start(self) -> None:
156159

157160
self._load_snapshot()
158161
self._stop_event.clear()
162+
safe_id = _CONTROL_CHARS_RE.sub("", self._tenant_id)
159163
self._thread = threading.Thread(
160-
target=self._subscribe_loop, daemon=True, name=f"decree-watcher-{self._tenant_id}"
164+
target=self._subscribe_loop, daemon=True, name=f"decree-watcher-{safe_id}"
161165
)
162166
self._thread.start()
163167

sdk/tests/test_async_watcher.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,25 @@ async def empty_stream():
341341
stub.Subscribe.assert_called_once()
342342
_, sub_kwargs = stub.Subscribe.call_args
343343
assert sub_kwargs.get("metadata") == auth_meta
344+
345+
@pytest.mark.asyncio
346+
async def test_task_name_sanitizes_control_chars(self):
347+
stub = MagicMock()
348+
pb2 = MagicMock()
349+
mock_resp = MagicMock()
350+
mock_resp.config.values = []
351+
stub.GetConfig = AsyncMock(return_value=mock_resp)
352+
353+
async def empty_stream():
354+
return
355+
yield
356+
357+
stub.Subscribe.return_value = empty_stream()
358+
359+
w = AsyncConfigWatcher(stub, pb2, "tenant\x00evil\x1f", timeout=5.0)
360+
await w.start()
361+
assert w._task is not None
362+
assert "\x00" not in w._task.get_name()
363+
assert "\x1f" not in w._task.get_name()
364+
assert "tenantevil" in w._task.get_name()
365+
await w.stop()

sdk/tests/test_watcher.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,3 +403,19 @@ def cancel(self):
403403
# Thread must have joined within the timeout.
404404
assert not thread_ref.is_alive()
405405
assert w._thread is None
406+
407+
def test_thread_name_sanitizes_control_chars(self):
408+
stub = MagicMock()
409+
pb2 = MagicMock()
410+
mock_resp = MagicMock()
411+
mock_resp.config.values = []
412+
stub.GetConfig.return_value = mock_resp
413+
stub.Subscribe.return_value = iter([])
414+
415+
w = ConfigWatcher(stub, pb2, "tenant\x00evil\x1f", timeout=5.0)
416+
w.start()
417+
assert w._thread is not None
418+
assert "\x00" not in w._thread.name
419+
assert "\x1f" not in w._thread.name
420+
assert "tenantevil" in w._thread.name
421+
w.stop()

0 commit comments

Comments
 (0)