From d5f3b0b0bb6e22d35aa310f47325db92b5052edc Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 17 Jul 2026 12:25:26 +0800 Subject: [PATCH] fix: allow Docker API hostname in production --- deploy/docker-compose.server.yml | 1 + scripts/jyotish_api_server.py | 28 +++++++++++++++++++++++++--- tests/test_api_server_security.py | 20 ++++++++++++++++++++ tests/test_railway_deployment.py | 6 ++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 8c4a7ed4..98b3cbf7 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -7,6 +7,7 @@ services: env_file: ../.env.production environment: PORT: 5200 + JYOTISH_ALLOWED_HOSTS: localhost,127.0.0.1,::1,api expose: - "5200" healthcheck: diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 900e243f..eea17746 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1220,6 +1220,7 @@ DEFAULT_ALLOWED_ORIGINS = { 'http://localhost:5173', 'http://127.0.0.1:5173', } +DEFAULT_ALLOWED_HOSTS = {'localhost', '127.0.0.1', '::1'} MAX_REQUEST_BYTES = 2 * 1024 * 1024 MAX_IMPORT_FILE_BYTES = 1536 * 1024 MAX_IMPORT_TEXT_CHARS = 500_000 @@ -1399,7 +1400,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if origin and origin not in allowed: raise Forbidden('Origin is not allowed') host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower() - if host and host not in {'localhost', '127.0.0.1', '::1'}: + allowed_hosts = getattr(self.server, 'allowed_hosts', DEFAULT_ALLOWED_HOSTS) + if host and host not in allowed_hosts: raise Forbidden('Host is not allowed') if require_json: content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower() @@ -8346,11 +8348,18 @@ def _parse_allowed_origins(value): return {item.strip() for item in value.split(',') if item.strip()} -def start_server(port=5200, host='127.0.0.1', allowed_origins=None): +def _parse_allowed_hosts(value): + if not value: + return DEFAULT_ALLOWED_HOSTS + return {item.strip().lower() for item in value.split(',') if item.strip()} + + +def start_server(port=5200, host='127.0.0.1', allowed_origins=None, allowed_hosts=None): cleanup = prune_expired_async_jobs() server = ThreadingHTTPServer((host, port), JyotishAPIHandler) server.daemon_threads = True server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS + server.allowed_hosts = allowed_hosts or DEFAULT_ALLOWED_HOSTS print(f'Jyotish API v6.9.14 running on http://{host}:{port}') print(f" Async job cleanup: scanned={cleanup['scanned']}, removed={cleanup['removed']}") print(f' CORS origins: {", ".join(sorted(server.allowed_origins))}') @@ -8394,7 +8403,20 @@ if __name__ == '__main__': default=[], help='Allowed browser origin; may be repeated. Defaults to local Vite origins.', ) + parser.add_argument( + '--allow-host', + action='append', + default=[], + help='Allowed HTTP Host name; may be repeated. Defaults to loopback hosts.', + ) args = parser.parse_args() env_origins = _parse_allowed_origins(os.environ.get('JYOTISH_ALLOWED_ORIGINS')) cli_origins = set(args.allow_origin) - start_server(args.port, host=args.host, allowed_origins=cli_origins or env_origins) + env_hosts = _parse_allowed_hosts(os.environ.get('JYOTISH_ALLOWED_HOSTS')) + cli_hosts = {item.strip().lower() for item in args.allow_host if item.strip()} + start_server( + args.port, + host=args.host, + allowed_origins=cli_origins or env_origins, + allowed_hosts=cli_hosts or env_hosts, + ) diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 6d051c36..52ca2ab8 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -19,10 +19,12 @@ if SCRIPTS not in sys.path: import jyotish_api_server # noqa: E402 from jyotish_api_server import ( # noqa: E402 + DEFAULT_ALLOWED_HOSTS, DEFAULT_ALLOWED_ORIGINS, BadRequest, JyotishAPIHandler, _load_local_module, + _parse_allowed_hosts, _parse_allowed_origins, ) @@ -38,6 +40,7 @@ class _FakeHeaders(dict): class _FakeServer: allowed_origins = DEFAULT_ALLOWED_ORIGINS + allowed_hosts = DEFAULT_ALLOWED_HOSTS class _ResponseCaptureHandler(JyotishAPIHandler): @@ -171,6 +174,23 @@ def test_env_cors_parser_ignores_empty_entries() -> None: } +def test_internal_docker_host_must_be_explicitly_allowed() -> None: + handler = _handler() + handler.headers = _FakeHeaders({'Host': 'api:5200'}) + handler.path = '/health' + handler.server = _FakeServer() + + with pytest.raises(jyotish_api_server.Forbidden, match='Host'): + handler._enforce_request_security() + + handler.server.allowed_hosts = {'api'} + handler._enforce_request_security() + + +def test_env_host_parser_normalizes_configured_hosts() -> None: + assert _parse_allowed_hosts('API, ,localhost') == {'api', 'localhost'} + + def test_get_internal_errors_are_json_wrapped() -> None: handler = _ResponseCaptureHandler() diff --git a/tests/test_railway_deployment.py b/tests/test_railway_deployment.py index 5b438177..d490dd78 100644 --- a/tests/test_railway_deployment.py +++ b/tests/test_railway_deployment.py @@ -23,3 +23,9 @@ def test_web_image_copies_postcss_config_before_building_frontend() -> None: assert config_copy in web assert web.index(config_copy) < web.index("RUN npm run build") + + +def test_server_compose_allows_only_the_internal_api_hostname() -> None: + compose = (ROOT / "deploy" / "docker-compose.server.yml").read_text(encoding="utf-8") + + assert "JYOTISH_ALLOWED_HOSTS: localhost,127.0.0.1,::1,api" in compose