Files
hermes-webui/tests/test_issue1105_ssrf_custom_providers.py
nesquena-hermes 27b17a8fc8
Some checks failed
Release & Docker / release (push) Has been cancelled
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2 (#1117)
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts

- Add _copyText() helper: tries navigator.clipboard first, falls back to
  document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
  navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests

* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge

- User message attachments with image extensions now render as <img> via
  api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests

* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)

* fix: improve mobile touch responsiveness for session list items

iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
  to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
  double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch

Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
  to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
  double-tap detection — works consistently on mouse and touch

* fix(#1106): iterate custom_providers[].models dict keys for dropdown population

- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests

* fix(#1105): allow custom_providers hostnames through SSRF check

- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)

* fix(tests): update hover padding assertions for #1110 touch fix (absorb)

* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)

* fix: filter right/middle-click from pointerup handler (absorb)

* docs: v0.50.221 release notes and version bump

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
2026-04-26 10:36:59 -07:00

149 lines
5.5 KiB
Python

"""Tests for #1105 — SSRF check allows user-configured custom_providers hostnames.
The SSRF check blocks requests to private IPs unless the hostname is in a
hardcoded allowlist. This fix extracts hostnames from custom_providers config
and adds them to the trusted set, so user-explicitly configured local endpoints
(ollama, llama.cpp, vLLM, TabbyAPI, etc.) are not blocked.
"""
import os
import pytest
# ---------- Source-code analysis tests ----------
def test_ssrf_trusted_hosts_variable_exists():
"""The _ssrf_trusted_hosts set must be built from custom_providers config."""
with open("api/config.py") as f:
src = f.read()
assert "_ssrf_trusted_hosts" in src
assert "_ssrf_trusted_hosts: set[str] = set()" in src
def test_ssrf_trusted_hosts_populated_from_custom_providers():
"""Trusted hosts are extracted by iterating custom_providers[].base_url."""
with open("api/config.py") as f:
src = f.read()
# Must read custom_providers from cfg
assert 'cfg.get("custom_providers"' in src
# Must extract base_url from each entry
assert '_cp.get("base_url")' in src
# Must parse hostname with urlparse
assert "_cp_parsed.hostname" in src
# Must add to trusted set
assert "_ssrf_trusted_hosts.add" in src
def test_ssrf_check_uses_trusted_hosts():
"""The SSRF check must consult _ssrf_trusted_hosts before blocking."""
with open("api/config.py") as f:
src = f.read()
# The is_known_local check must include _ssrf_trusted_hosts
assert "in _ssrf_trusted_hosts" in src
def test_ssrf_known_local_still_present():
"""Original hardcoded allowlist must still be present (no regression)."""
with open("api/config.py") as f:
src = f.read()
for keyword in ("ollama", "localhost", "127.0.0.1", "lmstudio", "lm-studio"):
assert keyword in src, f"Missing hardcoded allowlist entry: {keyword}"
def test_ssrf_block_still_present():
"""SSRF ValueError must still be raised for unknown private IPs."""
with open("api/config.py") as f:
src = f.read()
assert 'SSRF: resolved hostname to private IP' in src
# ---------- Functional tests (mocked socket) ----------
def test_custom_provider_hostname_added_to_trusted():
"""A hostname from custom_providers base_url is added to trusted set."""
import api.config as config
import socket
from unittest.mock import patch, MagicMock
old_cfg = dict(config.cfg)
try:
config.cfg.update({
"model": {"model": "my-model", "base_url": "http://my-llama-server:8080/v1"},
"custom_providers": [
{"name": "my-llama", "base_url": "http://my-llama-server:8080/v1", "model": "llama-3"}
],
"providers": {},
})
config.invalidate_models_cache()
# Mock socket.getaddrinfo to return a private IP for my-llama-server
private_addr = ("192.168.1.100", None)
mock_getaddrinfo = MagicMock(return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", private_addr)
])
# Mock urllib to prevent actual HTTP call
mock_urlopen = MagicMock()
mock_urlopen.read.return_value = b'{"data": [{"id": "test-model"}]}'
mock_urlopen.__enter__ = MagicMock(return_value=mock_urlopen)
mock_urlopen.__exit__ = MagicMock(return_value=False)
with patch("socket.getaddrinfo", mock_getaddrinfo), \
patch("urllib.request.urlopen", mock_urlopen):
# Should NOT raise ValueError (SSRF) because hostname is in trusted set
result = config.get_available_models()
# Verify models were returned (auto-detection succeeded)
assert result is not None
assert "groups" in result
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config.invalidate_models_cache()
def test_unknown_private_ip_still_blocked():
"""A private IP from a hostname NOT in custom_providers is still blocked.
The SSRF ValueError is caught by the broad `except Exception` around
the custom endpoint fetch (line ~1571), so get_available_models() doesn't
crash — but no models are auto-detected from that endpoint.
"""
import api.config as config
import socket
from unittest.mock import patch, MagicMock
old_cfg = dict(config.cfg)
try:
config.cfg.update({
"model": {"model": "test", "base_url": "http://unknown-local-server:9999/v1"},
"custom_providers": [
{"name": "other", "base_url": "http://other-server:8080/v1", "model": "x"}
],
"providers": {},
})
config.invalidate_models_cache()
# Mock socket.getaddrinfo to return a private IP for unknown-local-server
private_addr = ("10.0.0.50", None)
mock_getaddrinfo = MagicMock(return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", private_addr)
])
with patch("socket.getaddrinfo", mock_getaddrinfo):
# Should NOT crash (ValueError is caught internally)
result = config.get_available_models()
# But no models should be auto-detected from the blocked endpoint
assert result is not None
assert "groups" in result
# Verify no group with "unknown-local-server" models exists
for group in result["groups"]:
provider_name = group.get("provider", "")
assert "unknown-local-server" not in provider_name
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config.invalidate_models_cache()