Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,15 @@ async def _handle_c2c_message(
)

# Process all attachments uniformly (images, voice, files)
att_result = await self._process_attachments(attachments_raw)
# SSRF protection: skip attachment fetching for unauthorized users
runner = getattr(self, "gateway_runner", None)
source_for_auth = self.build_source(
chat_id=user_openid, user_id=user_openid, chat_type="dm"
)
if runner is not None and hasattr(runner, "_is_user_authorized") and not runner._is_user_authorized(source_for_auth):
att_result = {"image_urls": [], "image_media_types": [], "voice_transcripts": [], "attachment_info": ""}
else:
att_result = await self._process_attachments(attachments_raw)
image_urls = att_result["image_urls"]
image_media_types = att_result["image_media_types"]
voice_transcripts = att_result["voice_transcripts"]
Expand Down
3 changes: 3 additions & 0 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,9 @@ def _validate_signature(
self, request: "web.Request", body: bytes, secret: str
) -> bool:
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
# Reject unresolved ${VAR} placeholder secrets — they are never valid
if re.search(r"\$\{[^}]+\}", secret):
return False
# GitHub: X-Hub-Signature-256 = sha256=<hex>
gh_sig = request.headers.get("X-Hub-Signature-256", "")
if gh_sig:
Expand Down
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5463,6 +5463,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
auth_user_id = user_id
if source.platform == Platform.WECOM_CALLBACK and source.chat_id:
auth_user_id = source.chat_id
team_id = getattr(source, "guild_id", None)
pairing_check_ids = [auth_user_id]
if team_id:
pairing_check_ids.insert(0, f"{team_id}:{auth_user_id}")
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) ->
"changeme",
"your_api_key",
"your-api-key",
"your_api_key_here",
"your-api-key-here",
"placeholder",
"example",
"dummy",
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def _looks_like_credential(name: str) -> bool:
"HERMES_SESSION_SOURCE",
"HERMES_SESSION_KEY",
"HERMES_GATEWAY_SESSION",
"HERMES_CRON_SESSION",
"HERMES_PLATFORM",
"HERMES_MODEL",
"HERMES_INFERENCE_MODEL",
Expand Down
9 changes: 9 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,15 @@ def test_placeholder_key_never_authenticates(self):
assert result is not None
assert result.status == 401

def test_documented_placeholder_key_never_authenticates(self):
config = PlatformConfig(enabled=True, extra={"key": "your_api_key_here"})
adapter = APIServerAdapter(config)
mock_request = MagicMock()
mock_request.headers = {"Authorization": "Bearer your_api_key_here"}
result = adapter._check_auth(mock_request)
assert result is not None
assert result.status == 401
Comment on lines +382 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure comprehensive test coverage for both newly added placeholder variations (your_api_key_here and your-api-key-here), consider parametrizing this test case.

Suggested change
def test_documented_placeholder_key_never_authenticates(self):
config = PlatformConfig(enabled=True, extra={"key": "your_api_key_here"})
adapter = APIServerAdapter(config)
mock_request = MagicMock()
mock_request.headers = {"Authorization": "Bearer your_api_key_here"}
result = adapter._check_auth(mock_request)
assert result is not None
assert result.status == 401
@pytest.mark.parametrize("placeholder", ["your_api_key_here", "your-api-key-here"])
def test_documented_placeholder_key_never_authenticates(self, placeholder):
config = PlatformConfig(enabled=True, extra={"key": placeholder})
adapter = APIServerAdapter(config)
mock_request = MagicMock()
mock_request.headers = {"Authorization": f"Bearer {placeholder}"}
result = adapter._check_auth(mock_request)
assert result is not None
assert result.status == 401



# ---------------------------------------------------------------------------
# Helpers for HTTP tests
Expand Down
21 changes: 21 additions & 0 deletions tests/hermes_cli/test_secret_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Tests for shared secret placeholder validation."""

import pytest

from hermes_cli.auth import has_usable_secret


@pytest.mark.parametrize(
"value",
[
"your_api_key_here",
"your-api-key-here",
"${API_SERVER_KEY}",
],
)
def test_has_usable_secret_rejects_predictable_placeholders(value):
assert has_usable_secret(value, min_length=8) is False


def test_has_usable_secret_accepts_generated_length_secret():
assert has_usable_secret("a" * 32, min_length=8) is True
4 changes: 4 additions & 0 deletions tools/process_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,10 @@ def _check_stdin_guards(self, session: ProcessSession, payload: str) -> Optional
approval = check_all_command_guards(self._stdin_guard_command(session, payload), "local")
if approval.get("approved"):
return None
# Normalise: callers (write_stdin, close_stdin) always return a dict
# with a "status" key; ensure blocked results conform to that contract.
if "status" not in approval:
approval = {**approval, "status": "blocked"}
return approval

def write_stdin(self, session_id: str, data: str) -> dict:
Expand Down
10 changes: 6 additions & 4 deletions website/docs/user-guide/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,24 @@ docker run -d \

Port 8642 exposes the gateway's [OpenAI-compatible API server](./features/api-server.md) and health endpoint. It's optional if you only use chat platforms (Telegram, Discord, etc.), but required if you want the dashboard or external tools to reach the gateway.

Note: the API server is gated on `API_SERVER_ENABLED=true`. To expose it beyond `127.0.0.1` inside the container, also set `API_SERVER_HOST=0.0.0.0` and an `API_SERVER_KEY` (minimum 8 characters — generate one with `openssl rand -hex 32`). Example:
Note: the API server is gated on `API_SERVER_ENABLED=true`. To expose it beyond `127.0.0.1` inside the container, also set `API_SERVER_HOST=0.0.0.0` and provide a unique `API_SERVER_KEY`. Generate the key outside the command, keep CORS restricted to trusted origins, and never copy a placeholder token into a network-exposed deployment. Example:

```sh
export HERMES_API_SERVER_KEY="$(openssl rand -hex 32)"

docker run -d \
--name hermes \
--restart unless-stopped \
-v ~/.hermes:/opt/data \
-p 8642:8642 \
-e API_SERVER_ENABLED=true \
-e API_SERVER_HOST=0.0.0.0 \
-e API_SERVER_KEY=your_api_key_here \
-e API_SERVER_CORS_ORIGINS='*' \
-e API_SERVER_KEY="$HERMES_API_SERVER_KEY" \
-e API_SERVER_CORS_ORIGINS='https://app.example.com' \
nousresearch/hermes-agent gateway run
```

Opening any port on an internet facing machine is a security risk. You should not do it unless you understand the risks.
Opening any port on an internet-facing machine is a security risk. Keep the API server behind a firewall or reverse proxy, use a high-entropy bearer key, and set `API_SERVER_CORS_ORIGINS` only to origins you trust.

## Running the dashboard

Expand Down
Loading