VYPR
Medium severity6.5NVD Advisory· Published Jun 9, 2026· Updated Jun 9, 2026

CVE-2026-49956

CVE-2026-49956

Description

Hermes WebUI versions prior to 0.51.269 allow authenticated users to bypass profile isolation and access other users' data via the session search endpoint.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Hermes WebUI versions prior to 0.51.269 allow authenticated users to bypass profile isolation and access other users' data via the session search endpoint.

Vulnerability

Hermes WebUI versions prior to 0.51.269 contain a profile isolation bypass vulnerability. This flaw exists in the session search endpoint, which fails to properly filter by active profile, allowing authenticated users to query and retrieve data from profiles other than their own.

Exploitation

An attacker must be authenticated to the Hermes WebUI. By sending specially crafted requests to the session search handler, an attacker can bypass profile restrictions and retrieve session titles and transcript message content from any user's profile, regardless of their own active profile.

Impact

Successful exploitation allows an authenticated attacker to access sensitive data, including session titles and message transcripts, belonging to other users. This constitutes an information disclosure vulnerability, compromising the privacy of user data within the application.

Mitigation

The vulnerability is fixed in Hermes WebUI version 0.51.277. Users should update to this version or later. The fix was included in pull request #3646 [2].

AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

2
2c7b530071bb

Release v0.51.269 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl) (#3672)

https://github.com/nesquena/hermes-webuinesquena-hermesJun 5, 2026via nvd-ref
11 files changed · +373 81
  • api/routes.py+23 4 modified
    @@ -8370,6 +8370,15 @@ def _handle_sessions_search(handler, parsed):
         qs = parse_qs(parsed.query)
         q = qs.get("q", [""])[0].lower().strip()
         content_search = qs.get("content", ["1"])[0] == "1"
    +    from api.profiles import get_active_profile_name
    +    active_profile = get_active_profile_name()
    +    all_profiles = _all_profiles_query_flag(parsed)
    +    sessions = all_sessions()
    +    if not all_profiles:
    +        sessions = [
    +            s for s in sessions
    +            if _profiles_match(s.get("profile"), active_profile)
    +        ]
         # Reject a malformed depth instead of letting int() raise ValueError and
         # surface as a confusing 500. Clamp to >= 0 so a negative value can't reach
         # the messages[:depth] slice below — messages[:-n] would silently exclude
    @@ -8381,14 +8390,18 @@ def _handle_sessions_search(handler, parsed):
             depth = 5
         if not q:
             safe_sessions = []
    -        for s in all_sessions():
    +        for s in sessions:
                 item = dict(s)
                 if isinstance(item.get("title"), str):
                     item["title"] = _redact_text(item["title"])
                 safe_sessions.append(item)
    -        return j(handler, {"sessions": safe_sessions})
    +        return j(handler, {
    +            "sessions": safe_sessions,
    +            "all_profiles": all_profiles,
    +            "active_profile": active_profile,
    +        })
         results = []
    -    for s in all_sessions():
    +    for s in sessions:
             title_match = q in (s.get("title") or "").lower()
             if title_match:
                 item = dict(s, match_type="title")
    @@ -8413,7 +8426,13 @@ def _handle_sessions_search(handler, parsed):
                             break
                 except (KeyError, Exception):
                     pass
    -    return j(handler, {"sessions": results, "query": q, "count": len(results)})
    +    return j(handler, {
    +        "sessions": results,
    +        "query": q,
    +        "count": len(results),
    +        "all_profiles": all_profiles,
    +        "active_profile": active_profile,
    +    })
     
     
     def _handle_list_dir(handler, parsed):
    
  • CHANGELOG.md+9 0 modified
    @@ -3,6 +3,15 @@
     
     ## [Unreleased]
     
    +## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl)
    +
    +### Fixed
    +- **Session search results are now scoped to the active profile** (matching how the sidebar already filters), so a search no longer surfaces sessions belonging to other profiles unless `?all_profiles=1` is requested. (#3646, @Hinotoi-agent)
    +- **`ctl.sh stop` now tree-kills the WebUI process on Windows (Git Bash/MSYS)** via `taskkill //F //T`, and resolves owned-process paths across Windows/POSIX path forms. POSIX behavior is unchanged (falls through to the existing `kill`/`kill -KILL`). (#3670, @rodboev)
    +
    +### Performance
    +- **The sidebar session list is partitioned in a single pass** instead of chaining five separate `.filter()` passes over the row set on every render, reducing per-render work for large session lists. Filtering/ordering/archived-count behavior is unchanged. (#3658, @pamnard)
    +
     ## [v0.51.268] — 2026-06-05 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes)
     
     ### Fixed
    
  • ctl.sh+81 6 modified
    @@ -165,21 +165,96 @@ _is_alive() {
       kill -0 "${pid}" >/dev/null 2>&1
     }
     
    -_proc_args() {
    +_is_windows_bash() {
    +  [[ "${OS:-}" == "Windows_NT" ]] && return 0
    +  case "$(uname -s 2>/dev/null || true)" in
    +    MINGW*|MSYS*|CYGWIN*) return 0 ;;
    +    *) return 1 ;;
    +  esac
    +}
    +
    +_windows_bash_path() {
    +  local path="${1//\\//}" drive rest
    +  if [[ "${path}" =~ ^([A-Za-z]):(.*)$ ]]; then
    +    drive="${BASH_REMATCH[1],,}"
    +    rest="${BASH_REMATCH[2]}"
    +    printf '/%s%s\n' "${drive}" "${rest}"
    +    return
    +  fi
    +  printf '%s\n' "${path}"
    +}
    +
    +_windows_pid_for_bash_pid() {
       local pid="$1"
    -  ps -p "${pid}" -o args= 2>/dev/null || true
    +  ps -p "${pid}" -l 2>/dev/null | awk 'NR == 2 { print $4 }'
    +}
    +
    +_stop_webui_pid() {
    +  local pid="$1" signal="${2:-TERM}"
    +  if _is_windows_bash && command -v taskkill >/dev/null 2>&1; then
    +    local winpid
    +    winpid="$(_windows_pid_for_bash_pid "${pid}")"
    +    if [[ "${winpid}" =~ ^[0-9]+$ ]]; then
    +      taskkill //F //T //PID "${winpid}" >/dev/null 2>&1 || true
    +      return
    +    fi
    +  fi
    +  if [[ "${signal}" == "KILL" ]]; then
    +    kill -KILL "${pid}" >/dev/null 2>&1 || true
    +  else
    +    kill "${pid}" >/dev/null 2>&1 || true
    +  fi
    +}
    +
    +_proc_args() {
    +  local pid="$1" args
    +  args="$(ps -p "${pid}" -o args= 2>/dev/null || true)"
    +  if [[ -n "${args}" ]]; then
    +    printf '%s\n' "${args}"
    +    return
    +  fi
    +  if _is_windows_bash; then
    +    local winpid
    +    winpid="$(_windows_pid_for_bash_pid "${pid}")"
    +    if [[ "${winpid}" =~ ^[0-9]+$ ]] && command -v wmic >/dev/null 2>&1; then
    +      args="$(wmic process where "ProcessId=${winpid}" get CommandLine //value 2>/dev/null | sed -n 's/^CommandLine=//p' | tr -d '\r')"
    +      if [[ -n "${args}" ]]; then
    +        printf '%s\n' "${args}"
    +        return
    +      fi
    +    fi
    +    ps -p "${pid}" -f 2>/dev/null | awk 'NR == 2 { for (i = 8; i <= NF; i++) printf "%s%s", (i == 8 ? "" : " "), $i; print "" }'
    +  fi
     }
     
     _is_owned_webui_pid() {
    -  local pid="$1" args state_repo="" state_python=""
    +  local pid="$1" args args_slash state_repo="" state_repo_slash="" state_repo_win="" state_repo_win_slash="" state_python="" state_python_slash="" state_python_bash=""
       [[ -f "${STATE_FILE}" ]] || return 1
       _load_state_if_present
       state_repo="${REPO_ROOT:-}"
       state_python="${PYTHON_EXE:-}"
    +  state_repo_slash="${state_repo//\\//}"
    +  state_python_slash="${state_python//\\//}"
    +  if _is_windows_bash; then
    +    state_repo_win="$(cygpath -w "${state_repo}" 2>/dev/null || true)"
    +    state_repo_win_slash="${state_repo_win//\\//}"
    +  fi
    +  if [[ -n "${state_python}" ]] && _is_windows_bash; then
    +    state_python_bash="$(_windows_bash_path "${state_python}")"
    +  fi
       [[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1
       args="$(_proc_args "${pid}")"
       [[ -n "${args}" ]] || return 1
    -  [[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* || ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ]]
    +  args_slash="${args//\\//}"
    +  [[ "${args_slash}" == *"${state_repo_slash}/bootstrap.py"* ||
    +     "${args_slash}" == *"${state_repo_slash}/server.py"* ||
    +     "${args_slash}" == *"${state_repo_slash}/start.sh"* ||
    +     ( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/bootstrap.py"* ) ||
    +     ( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/server.py"* ) ||
    +     ( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/start.sh"* ) ||
    +     ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ||
    +     ( -n "${state_python_slash}" && "${args_slash}" == *"${state_python_slash}"* ) ||
    +     ( -n "${state_python_bash}" && "${args_slash}" == *"${state_python_bash}"* ) ]]
     }
     
     _current_pid() {
    @@ -308,7 +383,7 @@ stop_cmd() {
       fi
     
       echo "[ctl] Stopping Hermes WebUI (PID ${pid})"
    -  kill "${pid}" >/dev/null 2>&1 || true
    +  _stop_webui_pid "${pid}" TERM
       local i
       for i in {1..50}; do
         if ! _is_alive "${pid}"; then
    @@ -320,7 +395,7 @@ stop_cmd() {
       done
     
       echo "[ctl] Process did not exit after SIGTERM; sending SIGKILL" >&2
    -  kill -KILL "${pid}" >/dev/null 2>&1 || true
    +  _stop_webui_pid "${pid}" KILL
       rm -f "${PID_FILE}" "${STATE_FILE}"
     }
     
    
  • static/sessions.js+56 41 modified
    @@ -4093,6 +4093,55 @@ function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtual
       });
     }
     
    +function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
    +  return (s.message_count||0)>0 ||
    +    _sessionAttentionState(s) ||
    +    _isSessionEffectivelyStreaming(s) ||
    +    !!s.active_stream_id ||
    +    !!s.pending_user_message ||
    +    (activeSidForSidebar&&s.session_id===activeSidForSidebar) ||
    +    (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
    +}
    +
    +function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
    +  let webuiSessionCount=0;
    +  let cliSessionCount=0;
    +  for(const s of allMatched){
    +    if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue;
    +    if(_isCliSession(s)) cliSessionCount++;
    +    else webuiSessionCount++;
    +  }
    +  if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){
    +    _sessionSourceFilter='webui';
    +  }
    +  const showCliOnly=_sessionSourceFilter==='cli';
    +  const profileFiltered=[];
    +  const sessionsRaw=[];
    +  let archivedCount=0;
    +  for(const s of allMatched){
    +    if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue;
    +    const isCli=_isCliSession(s);
    +    if(showCliOnly ? !isCli : isCli) continue;
    +    if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue;
    +    profileFiltered.push(s);
    +    if(_activeProject===NO_PROJECT_FILTER){
    +      if(s.project_id) continue;
    +    } else if(_activeProject){
    +      if(s.project_id!==_activeProject) continue;
    +    }
    +    if(s.archived) archivedCount++;
    +    if(!_showArchived&&s.archived) continue;
    +    sessionsRaw.push(s);
    +  }
    +  return {
    +    webuiSessionCount,
    +    cliSessionCount,
    +    profileFiltered,
    +    sessionsRaw,
    +    archivedCount,
    +  };
    +}
    +
     function renderSessionListFromCache(){
       // Don't re-render while user is actively renaming a session (would destroy the input)
       if(_renamingSid) return;
    @@ -4115,49 +4164,15 @@ function renderSessionListFromCache(){
       // session id into another conversation, that content hit should still appear.
       const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);
       const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);
    -  // Keep inactive ephemeral 0-message sessions out of the sidebar — they only
    -  // become real once the first message is sent. The server already filters them.
    -  // Exception: the active freshly-created chat is injected above so it remains
    -  // visible/selected until the user sends the first turn or switches away.
    -  const withMessages=allMatched.filter(s=>
    -    (s.message_count||0)>0 ||
    -    _sessionAttentionState(s) ||
    -    _isSessionEffectivelyStreaming(s) ||
    -    !!s.active_stream_id ||
    -    !!s.pending_user_message ||
    -    (activeSidForSidebar&&s.session_id===activeSidForSidebar) ||
    -    (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0)
    -  );
    -  const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length;
    -  const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length;
    -  if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){
    -    _sessionSourceFilter='webui';
    -  }
    -  const sourceFiltered = _sessionSourceFilter==='cli'
    -    ? withMessages.filter(s=>_isCliSession(s))
    -    : withMessages.filter(s=>!_isCliSession(s));
    -  // The server is authoritative for profile scoping (#1611): it filters by
    -  // active profile when no query param is set, and returns the aggregate when
    -  // we send ?all_profiles=1. The renamed-root cross-alias (a row tagged
    -  // 'default' matching active 'kinni' when kinni.is_default) lives server-side
    -  // in _profiles_match, and a strict-equality client filter would reject those
    -  // rows incorrectly. So we trust the wire data and skip the redundant client
    -  // filter entirely.
    -  const profileFiltered=sourceFiltered.filter(s=>
    -    !s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)
    -  );
    -  // Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions
    -  // with no project_id; otherwise filter to the matching project_id, or
    -  // pass through when no filter is active.
    -  const projectFiltered=
    -    _activeProject===NO_PROJECT_FILTER
    -      ?profileFiltered.filter(s=>!s.project_id)
    -      :(_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered);
    -  // Filter archived unless toggle is on
    -  const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
    +  const {
    +    webuiSessionCount,
    +    cliSessionCount,
    +    profileFiltered,
    +    sessionsRaw,
    +    archivedCount,
    +  }=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
       const sessions=_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw);
       _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar);
    -  const archivedCount=projectFiltered.filter(s=>s.archived).length;
       const list=$('sessionList');
       const animateRefresh=_sessionListRefreshAnimationPending;
       _sessionListRefreshAnimationPending=false;
    
  • tests/test_ctl_script.py+100 22 modified
    @@ -6,6 +6,8 @@
     import time
     from pathlib import Path
     
    +import pytest
    +
     
     REPO_ROOT = Path(__file__).resolve().parents[1]
     CTL = REPO_ROOT / "ctl.sh"
    @@ -78,33 +80,106 @@ def wait_for_pid_file(pid_file: Path, timeout: float = 3.0) -> int:
         raise AssertionError(f"PID file was not written: {pid_file}")
     
     
    -def wait_for_file_text(path: Path, timeout: float = 3.0) -> str:
    +def wait_for_file_text(path: Path, timeout: float = 3.0, contains: str | None = None) -> str:
         deadline = time.time() + timeout
         while time.time() < deadline:
             if path.exists():
                 text = path.read_text(encoding="utf-8")
    -            if text:
    +            if text and (contains is None or contains in text):
                     return text
             time.sleep(0.05)
         raise AssertionError(f"File was not written: {path}")
     
     
    +def assert_path_in_text(path: Path, text: str) -> None:
    +    assert str(path).replace("\\", "/") in text.replace("\\", "/")
    +
    +
    +def bash_path(path: Path) -> str:
    +    raw = str(path.resolve()).replace("\\", "/")
    +    if sys.platform == "win32" and len(raw) > 1 and raw[1] == ":":
    +        return f"/{raw[0].lower()}{raw[2:]}"
    +    return raw
    +
    +
    +def bash_pid(pid: int) -> int:
    +    if sys.platform != "win32":
    +        return pid
    +    result = subprocess.run(
    +        [
    +            "bash",
    +            "-lc",
    +            "ps -W | awk -v winpid=\"$1\" '$4 == winpid { print $1; exit }'",
    +            "_",
    +            str(pid),
    +        ],
    +        text=True,
    +        capture_output=True,
    +        timeout=3,
    +    )
    +    if result.returncode == 0 and result.stdout.strip():
    +        return int(result.stdout.strip())
    +    return pid
    +
    +
    +def windows_pid(pid: int) -> int | None:
    +    if sys.platform != "win32":
    +        return pid
    +    result = subprocess.run(
    +        [
    +            "bash",
    +            "-lc",
    +            "ps -p \"$1\" -l | awk 'NR == 2 { print $4 }'",
    +            "_",
    +            str(pid),
    +        ],
    +        text=True,
    +        capture_output=True,
    +        timeout=3,
    +    )
    +    if result.returncode == 0 and result.stdout.strip():
    +        return int(result.stdout.strip())
    +    return None
    +
    +
    +def start_fake_launchd_process() -> subprocess.Popen:
    +    return subprocess.Popen(["bash", "-lc", "exec sleep 30"])
    +
    +
     def _kill_tree(pid: int) -> None:
         if sys.platform == "win32":
    -        subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
    +        winpid = windows_pid(pid)
    +        if winpid is not None:
    +            subprocess.run(["taskkill", "/F", "/T", "/PID", str(winpid)], capture_output=True)
         else:
             try:
                 os.kill(pid, 9)
             except ProcessLookupError:
                 pass
     
     
    +def process_exists(pid: int) -> bool:
    +    if sys.platform == "win32":
    +        return (
    +            subprocess.run(
    +                ["bash", "-lc", "kill -0 \"$1\"", "_", str(pid)],
    +                text=True,
    +                capture_output=True,
    +                timeout=3,
    +            ).returncode
    +            == 0
    +        )
    +    try:
    +        os.kill(pid, 0)
    +        return True
    +    except ProcessLookupError:
    +        return False
    +
    +
     def assert_process_exits(pid: int, timeout: float = 3.0) -> None:
         deadline = time.time() + timeout
         while time.time() < deadline:
    -        try:
    -            os.kill(pid, 0)
    -        except ProcessLookupError:
    +        if not process_exists(pid):
                 return
             time.sleep(0.05)
         _kill_tree(pid)
    @@ -136,19 +211,20 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(
         try:
             assert pid > 1
             assert log_file.exists()
    -        fake_output = wait_for_file_text(fake_log)
    +        fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=18991")
             assert "bootstrap.py --no-browser --foreground" in fake_output
             assert "host=0.0.0.0 port=18991" in fake_output
    -        assert str(hermes_home / "webui") in fake_output
    +        assert_path_in_text(hermes_home / "webui", fake_output)
             status = run_ctl(tmp_path, "status")
             assert status.returncode == 0
             assert "running" in status.stdout
             assert f"PID:     {pid}" in status.stdout
             assert "Bound:   0.0.0.0:18991" in status.stdout
    -        assert f"Log:     {log_file}" in status.stdout
    +        assert_path_in_text(log_file, status.stdout)
         finally:
             stop = run_ctl(tmp_path, "stop")
             assert stop.returncode == 0, stop.stderr + stop.stdout
    +        _kill_tree(pid)
             assert_process_exits(pid)
             assert not pid_file.exists()
     
    @@ -187,12 +263,13 @@ def test_start_can_ignore_repo_dotenv_for_authoritative_test_env(tmp_path):
         assert result.returncode == 0, result.stderr + result.stdout
         pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
         try:
    -        fake_output = wait_for_file_text(fake_log)
    -        assert str(tmp_path / ".hermes" / "webui") in fake_output
    +        fake_output = wait_for_file_text(fake_log, contains="host=127.0.0.1 port=8787")
    +        assert_path_in_text(tmp_path / ".hermes" / "webui", fake_output)
             assert "host-specific-webui" not in fake_output
         finally:
             stop = run_ctl(tmp_path, "stop", repo_root=repo_root)
             assert stop.returncode == 0, stop.stderr + stop.stdout
    +        _kill_tree(pid)
             assert_process_exits(pid)
     
     
    @@ -225,12 +302,13 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
         assert result.returncode == 0, result.stderr + result.stdout
         pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
         try:
    -        fake_output = wait_for_file_text(fake_log)
    +        fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=18888")
             assert "fake-python args:" in fake_output
             assert "host=0.0.0.0 port=18888" in fake_output
         finally:
             stop = run_ctl(tmp_path, "stop", repo_root=repo_root)
             assert stop.returncode == 0, stop.stderr + stop.stdout
    +        _kill_tree(pid)
             assert_process_exits(pid)
     
     
    @@ -283,11 +361,14 @@ def _write_fake_lsof(fake_bin, listening):
     
     
     def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path):
    +    if sys.platform == "win32":
    +        pytest.skip("launchd conflict guard is a macOS path; fake launchctl PIDs are not stable under Git Bash")
    +
         fake_bin = tmp_path / "bin"
         fake_bin.mkdir()
     
    -    sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
    -    _write_fake_launchctl(fake_bin, sleeper.pid)
    +    sleeper = start_fake_launchd_process()
    +    _write_fake_launchctl(fake_bin, bash_pid(sleeper.pid))
         # launchd-owned PID IS listening on the requested (default) port → real conflict.
         _write_fake_lsof(fake_bin, listening=True)
     
    @@ -296,7 +377,7 @@ def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path):
                 tmp_path,
                 "start",
                 env={
    -                "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
    +                "PATH": f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}",
                     "HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
                 },
             )
    @@ -320,8 +401,8 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path)
         fake_bin = tmp_path / "bin"
         fake_bin.mkdir()
     
    -    sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
    -    _write_fake_launchctl(fake_bin, sleeper.pid)
    +    sleeper = start_fake_launchd_process()
    +    _write_fake_launchctl(fake_bin, bash_pid(sleeper.pid))
         # launchd-owned PID is alive but NOT listening on our (alternate) port → no conflict.
         _write_fake_lsof(fake_bin, listening=False)
     
    @@ -339,7 +420,7 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path)
                 tmp_path,
                 "start",
                 env={
    -                "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
    +                "PATH": f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}",
                     "HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
                     "HERMES_WEBUI_PORT": "18992",
                     "HERMES_WEBUI_PYTHON": str(fake_python),
    @@ -353,10 +434,7 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path)
                 started_pid = int(pid_file.read_text().strip())
         finally:
             if started_pid:
    -            try:
    -                os.kill(started_pid, 15)
    -            except ProcessLookupError:
    -                pass
    +            _kill_tree(started_pid)
             sleeper.terminate()
             try:
                 sleeper.wait(timeout=3)
    
  • tests/test_issue2351_cli_session_source_filter.py+5 4 modified
    @@ -18,10 +18,11 @@ def test_sidebar_has_separate_webui_and_cli_session_source_tabs():
     
     def test_cli_filter_keeps_cli_rows_out_of_default_webui_list():
         src = SESSIONS_JS.read_text(encoding="utf-8")
    -    assert "const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length" in src
    -    assert "const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length" in src
    -    assert "? withMessages.filter(s=>_isCliSession(s))" in src
    -    assert ": withMessages.filter(s=>!_isCliSession(s))" in src
    +    assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src
    +    assert "webuiSessionCount" in src
    +    assert "cliSessionCount" in src
    +    assert "const showCliOnly=_sessionSourceFilter==='cli';" in src
    +    assert "if(showCliOnly ? !isCli : isCli) continue;" in src
     
     
     def test_session_source_tabs_have_dedicated_sidebar_styles():
    
  • tests/test_issue3019_cron_project_sessions.py+2 1 modified
    @@ -91,4 +91,5 @@ def test_agent_side_cron_rows_keep_project_chip_visibility():
     def test_session_list_project_filter_can_reveal_default_hidden_cron_rows():
         src = ( __import__("pathlib").Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8")
     
    -    assert "!s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)" in src
    +    assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src
    +    assert "if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue;" in src
    
  • tests/test_sessions_search_depth_validation.py+4 2 modified
    @@ -25,7 +25,7 @@ def _run_search(query):
         lives in its LAST message, capturing the JSON payload/status."""
         import api.routes as routes
     
    -    sessions_meta = [{"session_id": "s1", "title": "Untitled"}]
    +    sessions_meta = [{"session_id": "s1", "title": "Untitled", "profile": "default"}]
         session = SimpleNamespace(
             session_id="s1",
             messages=[
    @@ -42,7 +42,9 @@ def fake_j(handler, payload, status=200, extra_headers=None):
     
         with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch(
             "api.routes.get_session", return_value=session
    -    ), patch("api.routes.j", side_effect=fake_j):
    +    ), patch("api.profiles.get_active_profile_name", return_value="default"), patch(
    +        "api.routes.j", side_effect=fake_j
    +    ):
             routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
         return captured
     
    
  • tests/test_sessions_search_profile_scope.py+52 0 added
    @@ -0,0 +1,52 @@
    +from types import SimpleNamespace
    +from unittest.mock import patch
    +from urllib.parse import urlparse
    +
    +
    +def _run_search(query):
    +    import api.routes as routes
    +    sessions_meta = [
    +        {"session_id": "active-s", "title": "boring", "profile": "default"},
    +        {"session_id": "other-s", "title": "secret needle title", "profile": "other"},
    +        {"session_id": "other-content-s", "title": "boring", "profile": "other"},
    +    ]
    +    sessions = {
    +        "other-content-s": SimpleNamespace(messages=[{"role": "user", "content": "secret needle body"}]),
    +        "active-s": SimpleNamespace(messages=[{"role": "user", "content": "nothing"}]),
    +        "other-s": SimpleNamespace(messages=[]),
    +    }
    +    captured = {}
    +    def fake_j(handler, payload, status=200, extra_headers=None):
    +        captured["status"] = status
    +        captured["payload"] = payload
    +    with patch("api.routes.all_sessions", return_value=list(sessions_meta)), \
    +         patch("api.routes.get_session", side_effect=lambda sid: sessions[sid]), \
    +         patch("api.profiles.get_active_profile_name", return_value="default"), \
    +         patch("api.routes.j", side_effect=fake_j):
    +        routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
    +    return captured
    +
    +
    +def test_empty_session_search_scopes_to_active_profile():
    +    captured = _run_search("/api/sessions/search")
    +    assert [s["session_id"] for s in captured["payload"]["sessions"]] == ["active-s"]
    +
    +
    +def test_title_search_should_not_return_other_profile_rows():
    +    captured = _run_search("/api/sessions/search?q=needle&content=0")
    +    assert captured["payload"]["count"] == 0
    +
    +
    +def test_content_search_should_not_return_other_profile_rows():
    +    captured = _run_search("/api/sessions/search?q=needle&content=1&depth=0")
    +    assert captured["payload"]["count"] == 0
    +
    +
    +def test_all_profiles_opt_in_keeps_aggregate_session_search():
    +    captured = _run_search("/api/sessions/search?all_profiles=1")
    +    assert captured["payload"]["all_profiles"] is True
    +    assert [s["session_id"] for s in captured["payload"]["sessions"]] == [
    +        "active-s",
    +        "other-s",
    +        "other-content-s",
    +    ]
    
  • tests/test_sidebar_session_partition.py+37 0 added
    @@ -0,0 +1,37 @@
    +"""Regression coverage for single-pass sidebar session partitioning."""
    +
    +from __future__ import annotations
    +
    +from pathlib import Path
    +
    +ROOT = Path(__file__).resolve().parents[1]
    +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
    +
    +
    +def _partition_block() -> str:
    +    start = SESSIONS_JS.index("function _partitionSidebarSessionRows(")
    +    end = SESSIONS_JS.index("function renderSessionListFromCache()", start)
    +    return SESSIONS_JS[start:end]
    +
    +
    +def test_render_uses_single_pass_partition_helper():
    +    render_start = SESSIONS_JS.index("function renderSessionListFromCache()")
    +    render_end = SESSIONS_JS.index("function _showProjectPicker", render_start)
    +    render_body = SESSIONS_JS[render_start:render_end]
    +
    +    assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body
    +    assert "withMessages.filter(" not in render_body
    +
    +
    +def test_partition_helper_applies_message_source_project_and_archive_gates():
    +    block = _partition_block()
    +
    +    assert "function _sidebarRowHasVisibleMessages(s, activeSidForSidebar)" in SESSIONS_JS
    +    assert "_sidebarRowHasVisibleMessages(s, activeSidForSidebar)" in block
    +    assert "if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0)" in block
    +    assert "const showCliOnly=_sessionSourceFilter==='cli';" in block
    +    assert "if(!_showArchived&&s.archived) continue;" in block
    +    assert "if(s.archived) archivedCount++;" in block
    +    assert "return {" in block
    +    assert "profileFiltered," in block
    +    assert "sessionsRaw," in block
    
  • tests/test_sidebar_unassigned_filter.py+4 1 modified
    @@ -58,9 +58,12 @@ def test_unassigned_chip_filter_logic():
         assert "_activeProject===NO_PROJECT_FILTER" in js, (
             "renderSessionListFromCache must branch on the NO_PROJECT_FILTER sentinel"
         )
    -    assert "profileFiltered.filter(s=>!s.project_id)" in js, (
    +    assert "if(_activeProject===NO_PROJECT_FILTER){" in js, (
             "The Unassigned filter must select sessions without a project_id"
         )
    +    assert "if(s.project_id) continue;" in js, (
    +        "The Unassigned filter must skip sessions with a project_id"
    +    )
     
     
     def test_unassigned_chip_only_shown_when_relevant():
    
dca1eb427e98

Merge ff437da4c778ec9a00900bbef5143ad5447bd0b2 into 4c545a33f3ae98c7795f211d85ef51b7ce018e0a

https://github.com/nesquena/hermes-webuiHinotobiJun 5, 2026via nvd-ref
3 files changed · +79 6
  • api/routes.py+23 4 modified
    @@ -8335,6 +8335,15 @@ def _handle_sessions_search(handler, parsed):
         qs = parse_qs(parsed.query)
         q = qs.get("q", [""])[0].lower().strip()
         content_search = qs.get("content", ["1"])[0] == "1"
    +    from api.profiles import get_active_profile_name
    +    active_profile = get_active_profile_name()
    +    all_profiles = _all_profiles_query_flag(parsed)
    +    sessions = all_sessions()
    +    if not all_profiles:
    +        sessions = [
    +            s for s in sessions
    +            if _profiles_match(s.get("profile"), active_profile)
    +        ]
         # Reject a malformed depth instead of letting int() raise ValueError and
         # surface as a confusing 500. Clamp to >= 0 so a negative value can't reach
         # the messages[:depth] slice below — messages[:-n] would silently exclude
    @@ -8346,14 +8355,18 @@ def _handle_sessions_search(handler, parsed):
             depth = 5
         if not q:
             safe_sessions = []
    -        for s in all_sessions():
    +        for s in sessions:
                 item = dict(s)
                 if isinstance(item.get("title"), str):
                     item["title"] = _redact_text(item["title"])
                 safe_sessions.append(item)
    -        return j(handler, {"sessions": safe_sessions})
    +        return j(handler, {
    +            "sessions": safe_sessions,
    +            "all_profiles": all_profiles,
    +            "active_profile": active_profile,
    +        })
         results = []
    -    for s in all_sessions():
    +    for s in sessions:
             title_match = q in (s.get("title") or "").lower()
             if title_match:
                 item = dict(s, match_type="title")
    @@ -8378,7 +8391,13 @@ def _handle_sessions_search(handler, parsed):
                             break
                 except (KeyError, Exception):
                     pass
    -    return j(handler, {"sessions": results, "query": q, "count": len(results)})
    +    return j(handler, {
    +        "sessions": results,
    +        "query": q,
    +        "count": len(results),
    +        "all_profiles": all_profiles,
    +        "active_profile": active_profile,
    +    })
     
     
     def _handle_list_dir(handler, parsed):
    
  • tests/test_sessions_search_depth_validation.py+4 2 modified
    @@ -25,7 +25,7 @@ def _run_search(query):
         lives in its LAST message, capturing the JSON payload/status."""
         import api.routes as routes
     
    -    sessions_meta = [{"session_id": "s1", "title": "Untitled"}]
    +    sessions_meta = [{"session_id": "s1", "title": "Untitled", "profile": "default"}]
         session = SimpleNamespace(
             session_id="s1",
             messages=[
    @@ -42,7 +42,9 @@ def fake_j(handler, payload, status=200, extra_headers=None):
     
         with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch(
             "api.routes.get_session", return_value=session
    -    ), patch("api.routes.j", side_effect=fake_j):
    +    ), patch("api.profiles.get_active_profile_name", return_value="default"), patch(
    +        "api.routes.j", side_effect=fake_j
    +    ):
             routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
         return captured
     
    
  • tests/test_sessions_search_profile_scope.py+52 0 added
    @@ -0,0 +1,52 @@
    +from types import SimpleNamespace
    +from unittest.mock import patch
    +from urllib.parse import urlparse
    +
    +
    +def _run_search(query):
    +    import api.routes as routes
    +    sessions_meta = [
    +        {"session_id": "active-s", "title": "boring", "profile": "default"},
    +        {"session_id": "other-s", "title": "secret needle title", "profile": "other"},
    +        {"session_id": "other-content-s", "title": "boring", "profile": "other"},
    +    ]
    +    sessions = {
    +        "other-content-s": SimpleNamespace(messages=[{"role": "user", "content": "secret needle body"}]),
    +        "active-s": SimpleNamespace(messages=[{"role": "user", "content": "nothing"}]),
    +        "other-s": SimpleNamespace(messages=[]),
    +    }
    +    captured = {}
    +    def fake_j(handler, payload, status=200, extra_headers=None):
    +        captured["status"] = status
    +        captured["payload"] = payload
    +    with patch("api.routes.all_sessions", return_value=list(sessions_meta)), \
    +         patch("api.routes.get_session", side_effect=lambda sid: sessions[sid]), \
    +         patch("api.profiles.get_active_profile_name", return_value="default"), \
    +         patch("api.routes.j", side_effect=fake_j):
    +        routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
    +    return captured
    +
    +
    +def test_empty_session_search_scopes_to_active_profile():
    +    captured = _run_search("/api/sessions/search")
    +    assert [s["session_id"] for s in captured["payload"]["sessions"]] == ["active-s"]
    +
    +
    +def test_title_search_should_not_return_other_profile_rows():
    +    captured = _run_search("/api/sessions/search?q=needle&content=0")
    +    assert captured["payload"]["count"] == 0
    +
    +
    +def test_content_search_should_not_return_other_profile_rows():
    +    captured = _run_search("/api/sessions/search?q=needle&content=1&depth=0")
    +    assert captured["payload"]["count"] == 0
    +
    +
    +def test_all_profiles_opt_in_keeps_aggregate_session_search():
    +    captured = _run_search("/api/sessions/search?all_profiles=1")
    +    assert captured["payload"]["all_profiles"] is True
    +    assert [s["session_id"] for s in captured["payload"]["sessions"]] == [
    +        "active-s",
    +        "other-s",
    +        "other-content-s",
    +    ]
    

Vulnerability mechanics

Root cause

"The session search endpoint did not filter results by the active profile."

Attack vector

An authenticated user can send a request to the session search endpoint without specifying any search query parameters. This allows them to bypass the active profile filtering and retrieve session titles and message content from other users' profiles. The vulnerability exists because the backend does not enforce profile scoping by default when no query parameters are provided [ref_id=1].

Affected code

The vulnerability resides in the `_handle_sessions_search` function within `api/routes.py`. The fix is implemented in the same function, specifically in the logic that retrieves and filters sessions before returning them.

What the fix does

The patch modifies the `_handle_sessions_search` function in `api/routes.py` to include profile filtering. If the `all_profiles` query flag is not set, the code now explicitly filters the sessions to only include those belonging to the active profile, obtained via `get_active_profile_name()` [ref_id=1]. This ensures that search results are scoped to the user's current profile, preventing unauthorized data access.

Preconditions

  • authThe attacker must be an authenticated user.

Generated on Jun 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.