Skip to content
Merged
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
6 changes: 4 additions & 2 deletions docs/en/guides/04-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,19 @@ client = ov.SyncHTTPClient(

## Development Mode

When no `root_api_key` is configured, authentication is disabled. All requests are accepted as ROOT with the default account.
When no `root_api_key` is configured, authentication is disabled. All requests are accepted as ROOT with the default account. **This is only allowed when the server binds to localhost** (`127.0.0.1`, `localhost`, or `::1`). If `host` is set to a non-loopback address (e.g. `0.0.0.0`) without a `root_api_key`, the server will refuse to start.

```json
{
"server": {
"host": "0.0.0.0",
"host": "127.0.0.1",
"port": 1933
}
}
```

> **Security note:** The default `host` is `127.0.0.1`. If you need to expose the server on the network, you **must** configure `root_api_key`.

## Unauthenticated Endpoints

The `/health` endpoint never requires authentication. This allows load balancers and monitoring tools to check server health.
Expand Down
6 changes: 4 additions & 2 deletions docs/zh/guides/04-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,19 @@ client = ov.SyncHTTPClient(

## 开发模式

不配置 `root_api_key` 时,认证禁用所有请求以 ROOT 身份访问 default account。
不配置 `root_api_key` 时,认证禁用所有请求以 ROOT 身份访问 default account。**此模式仅允许在服务器绑定 localhost 时使用**(`127.0.0.1`、`localhost` 或 `::1`)。如果 `host` 设置为非回环地址(如 `0.0.0.0`)且未配置 `root_api_key`,服务器将拒绝启动

```json
{
"server": {
"host": "0.0.0.0",
"host": "127.0.0.1",
"port": 1933
}
}
```

> **安全提示:** 默认 `host` 为 `127.0.0.1`。如需将服务暴露到网络,**必须**配置 `root_api_key`。

## 无需认证的端点

`/health` 端点始终不需要认证,用于负载均衡器和监控工具检查服务健康状态。
Expand Down
12 changes: 10 additions & 2 deletions openviking/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from fastapi.responses import JSONResponse

from openviking.server.api_keys import APIKeyManager
from openviking.server.config import ServerConfig, load_server_config
from openviking.server.config import ServerConfig, load_server_config, validate_server_config
from openviking.server.dependencies import set_service
from openviking.server.models import ERROR_CODE_TO_HTTP_STATUS, ErrorInfo, Response
from openviking.server.routers import (
Expand Down Expand Up @@ -50,6 +50,8 @@ def create_app(
if config is None:
config = load_server_config()

validate_server_config(config)

@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
Expand All @@ -72,7 +74,13 @@ async def lifespan(app: FastAPI):
logger.info("APIKeyManager initialized")
else:
app.state.api_key_manager = None
logger.info("Dev mode: no root_api_key configured, authentication disabled")
logger.warning(
"Dev mode: no root_api_key configured, authentication disabled. "
"This is allowed because the server is bound to localhost (%s). "
"Do NOT expose this server to the network without configuring "
"server.root_api_key in ov.conf.",
config.host,
)

yield

Expand Down
45 changes: 43 additions & 2 deletions openviking/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@
# SPDX-License-Identifier: Apache-2.0
"""Server configuration for OpenViking HTTP Server."""

import sys
from dataclasses import dataclass, field
from typing import List, Optional

from openviking_cli.utils import get_logger
from openviking_cli.utils.config.config_loader import (
DEFAULT_OV_CONF,
OPENVIKING_CONFIG_ENV,
load_json_config,
resolve_config_path,
)

logger = get_logger(__name__)


@dataclass
class ServerConfig:
"""Server configuration (from the ``server`` section of ov.conf)."""

host: str = "0.0.0.0"
host: str = "127.0.0.1"
port: int = 1933
root_api_key: Optional[str] = None
cors_origins: List[str] = field(default_factory=lambda: ["*"])
Expand Down Expand Up @@ -59,10 +63,47 @@ def load_server_config(config_path: Optional[str] = None) -> ServerConfig:
server_data = data.get("server", {})

config = ServerConfig(
host=server_data.get("host", "0.0.0.0"),
host=server_data.get("host", "127.0.0.1"),
port=server_data.get("port", 1933),
root_api_key=server_data.get("root_api_key"),
cors_origins=server_data.get("cors_origins", ["*"]),
)

return config


_LOCALHOST_HOSTS = {"127.0.0.1", "localhost", "::1"}


def _is_localhost(host: str) -> bool:
"""Return True if *host* resolves to a loopback address."""
return host in _LOCALHOST_HOSTS


def validate_server_config(config: ServerConfig) -> None:
"""Validate server config for safe startup.

When ``root_api_key`` is not set, authentication is disabled (dev mode).
This is only acceptable when the server binds to localhost. Binding to a
non-loopback address without authentication exposes an unauthenticated ROOT
endpoint to the network.

Raises:
SystemExit: If the configuration is unsafe.
"""
if config.root_api_key:
return

if not _is_localhost(config.host):
logger.error(
"SECURITY: server.root_api_key is not configured and server.host "
"is '%s' (non-localhost). This would expose an unauthenticated "
"ROOT endpoint to the network.",
config.host,
)
logger.error(
"To fix, either:\n"
" 1. Set server.root_api_key in ov.conf, or\n"
' 2. Bind to localhost (server.host = "127.0.0.1")'
)
sys.exit(1)
40 changes: 39 additions & 1 deletion tests/server/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
"""Tests for multi-tenant authentication (openviking/server/auth.py)."""

import httpx
import pytest
import pytest_asyncio

from openviking.server.app import create_app
from openviking.server.config import ServerConfig
from openviking.server.config import ServerConfig, _is_localhost, validate_server_config
from openviking.server.dependencies import set_service
from openviking.service.core import OpenVikingService
from openviking_cli.session.user_id import UserIdentifier
Expand Down Expand Up @@ -199,3 +200,40 @@ async def test_cross_tenant_session_get_returns_not_found(auth_client: httpx.Asy
)
assert cross_get.status_code == 404
assert cross_get.json()["error"]["code"] == "NOT_FOUND"


# ---- _is_localhost tests ----


@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1"])
def test_is_localhost_true(host: str):
assert _is_localhost(host) is True


@pytest.mark.parametrize("host", ["0.0.0.0", "::", "192.168.1.1", "10.0.0.1"])
def test_is_localhost_false(host: str):
assert _is_localhost(host) is False


# ---- validate_server_config tests ----


def test_validate_no_key_localhost_passes():
"""No root_api_key + localhost should pass validation."""
for host in ("127.0.0.1", "localhost", "::1"):
config = ServerConfig(host=host, root_api_key=None)
validate_server_config(config) # should not raise


def test_validate_no_key_non_localhost_raises():
"""No root_api_key + non-localhost should raise SystemExit."""
config = ServerConfig(host="0.0.0.0", root_api_key=None)
with pytest.raises(SystemExit):
validate_server_config(config)


def test_validate_with_key_any_host_passes():
"""With root_api_key set, any host should pass validation."""
for host in ("0.0.0.0", "::", "192.168.1.1", "127.0.0.1"):
config = ServerConfig(host=host, root_api_key="some-secret-key")
validate_server_config(config) # should not raise
Loading