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
23 changes: 21 additions & 2 deletions deploy/docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,31 @@ if [[ -z "${CRAWL4AI_API_TOKEN:-}" && -f /run/secrets/api_token ]]; then
fi

# --- Bind resolution: loopback unless a credential is present. ---------------
PORT="${CRAWL4AI_PORT:-11235}"
DEFAULT_PORT="11235"
resolve_port() {
local port="${CRAWL4AI_PORT:-$DEFAULT_PORT}"
if [[ ! "$port" =~ ^[0-9]+$ ]]; then
if [[ "$port" == tcp://* ]]; then
echo "entrypoint: ignoring non-numeric CRAWL4AI_PORT='${port}' " \
"(looks like a Kubernetes service link); using ${DEFAULT_PORT}." >&2
port="$DEFAULT_PORT"
else
echo "entrypoint: CRAWL4AI_PORT must be numeric, got '${port}'." >&2
exit 1
fi
fi
printf '%s\n' "$port"
}

if [[ -n "${CRAWL4AI_API_TOKEN:-}" || "${CRAWL4AI_JWT_ENABLED:-false}" == "true" ]]; then
# A credential is configured -> the operator may expose all interfaces.
GUNICORN_BIND="${GUNICORN_BIND:-[::]:${PORT}}"
if [[ -z "${GUNICORN_BIND:-}" ]]; then
PORT="$(resolve_port)"
GUNICORN_BIND="[::]:${PORT}"
fi
else
# No credential -> refuse to expose; serve loopback only.
PORT="$(resolve_port)"
GUNICORN_BIND="127.0.0.1:${PORT}"
echo "entrypoint: no CRAWL4AI_API_TOKEN set; binding loopback only (${GUNICORN_BIND})." >&2
fi
Expand Down
98 changes: 98 additions & 0 deletions deploy/docker/tests/test_security_container_posture.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import os
import re
import stat
import subprocess

import pytest

Expand Down Expand Up @@ -110,6 +112,102 @@ def test_entrypoint_exists_and_resolves_bind(self):
assert "GUNICORN_BIND" in src and "REDIS_PASSWORD" in src
assert "127.0.0.1" in src # loopback default when no credential

def _run_entrypoint(self, tmp_path, extra_env):
fake_supervisord = tmp_path / "supervisord"
fake_supervisord.write_text(
"#!/usr/bin/env sh\n"
"echo \"GUNICORN_BIND=${GUNICORN_BIND}\"\n"
"echo \"REDIS_PASSWORD_SET=${REDIS_PASSWORD:+yes}\"\n",
encoding="utf-8",
)
fake_supervisord.chmod(
fake_supervisord.stat().st_mode | stat.S_IXUSR
)

env = os.environ.copy()
for key in (
"CRAWL4AI_API_TOKEN",
"CRAWL4AI_JWT_ENABLED",
"CRAWL4AI_PORT",
"GUNICORN_BIND",
"REDIS_PASSWORD",
):
env.pop(key, None)
env.update(extra_env)
env["PATH"] = f"{tmp_path}{os.pathsep}{env['PATH']}"

return subprocess.run(
["bash", os.path.join(DOCKER_DIR, "entrypoint.sh")],
cwd=DOCKER_DIR,
env=env,
text=True,
capture_output=True,
timeout=10,
check=False,
)

def test_entrypoint_preserves_ipv6_wildcard_when_token_allows_exposure(self, tmp_path):
result = self._run_entrypoint(
tmp_path,
{
"CRAWL4AI_API_TOKEN": "test-token",
"CRAWL4AI_JWT_ENABLED": "false",
"CRAWL4AI_PORT": "11235",
},
)
assert result.returncode == 0, result.stderr
assert "GUNICORN_BIND=[::]:11235" in result.stdout

def test_entrypoint_ignores_kubernetes_service_link_port_env(self, tmp_path):
result = self._run_entrypoint(
tmp_path,
{
"CRAWL4AI_API_TOKEN": "test-token",
"CRAWL4AI_JWT_ENABLED": "false",
"CRAWL4AI_PORT": "tcp://10.0.101.25:80",
},
)
assert result.returncode == 0, result.stderr
assert "GUNICORN_BIND=[::]:11235" in result.stdout
assert "ignoring non-numeric CRAWL4AI_PORT" in result.stderr

def test_entrypoint_ignores_service_link_port_env_without_token(self, tmp_path):
result = self._run_entrypoint(
tmp_path,
{
"CRAWL4AI_JWT_ENABLED": "false",
"CRAWL4AI_PORT": "tcp://10.0.101.25:80",
},
)
assert result.returncode == 0, result.stderr
assert "GUNICORN_BIND=127.0.0.1:11235" in result.stdout
assert "ignoring non-numeric CRAWL4AI_PORT" in result.stderr

def test_entrypoint_preserves_explicit_gunicorn_bind(self, tmp_path):
result = self._run_entrypoint(
tmp_path,
{
"CRAWL4AI_API_TOKEN": "test-token",
"CRAWL4AI_JWT_ENABLED": "false",
"CRAWL4AI_PORT": "not-a-port",
"GUNICORN_BIND": "0.0.0.0:9999",
},
)
assert result.returncode == 0, result.stderr
assert "GUNICORN_BIND=0.0.0.0:9999" in result.stdout

def test_entrypoint_rejects_other_non_numeric_port_env(self, tmp_path):
result = self._run_entrypoint(
tmp_path,
{
"CRAWL4AI_API_TOKEN": "test-token",
"CRAWL4AI_JWT_ENABLED": "false",
"CRAWL4AI_PORT": "http://10.0.101.25:80",
},
)
assert result.returncode == 1
assert "CRAWL4AI_PORT must be numeric" in result.stderr


class TestSandboxOptOut:
def test_default_keeps_no_sandbox(self, server_module, monkeypatch):
Expand Down