diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh index b624a3115..3d0037916 100644 --- a/deploy/docker/entrypoint.sh +++ b/deploy/docker/entrypoint.sh @@ -31,6 +31,8 @@ else # No credential -> refuse to expose; serve loopback only. GUNICORN_BIND="127.0.0.1:${PORT}" echo "entrypoint: no CRAWL4AI_API_TOKEN set; binding loopback only (${GUNICORN_BIND})." >&2 + echo "entrypoint: WARNING: this is the CONTAINER's loopback - published ports (-p ${PORT}:${PORT}) will NOT work; connections from the host will be reset." >&2 + echo "entrypoint: to make the server reachable, set CRAWL4AI_API_TOKEN (docker run -e CRAWL4AI_API_TOKEN=..., or the .llm.env file with docker compose), then restart. (If you enabled security.jwt_enabled in a custom config.yml, set CRAWL4AI_JWT_ENABLED=true instead.)" >&2 fi export GUNICORN_BIND diff --git a/deploy/docker/schemas.py b/deploy/docker/schemas.py index f066e5c88..978f21999 100644 --- a/deploy/docker/schemas.py +++ b/deploy/docker/schemas.py @@ -42,6 +42,14 @@ class HookConfig(BaseModel): le=120, description="Timeout in seconds for each hook execution", ) + # Legacy 0.8.x field: inline-Python hook code, removed in 0.9.0 (it was an + # exec()-based RCE surface). Captured here (instead of being dropped by + # pydantic) solely so the server can tell the caller it was NOT executed; + # it is never run. + code: Optional[Any] = Field( + default=None, + description="REMOVED in 0.9.0: inline hook code is accepted for compatibility but never executed", + ) class Config: json_schema_extra = { @@ -84,14 +92,29 @@ class ScreenshotRequest(BaseModel): url: str screenshot_wait_for: Optional[float] = 2 wait_for_images: Optional[bool] = False - # output_path removed: callers never name a filesystem path (it was an - # arbitrary-write -> RCE vector). The server writes to the sandboxed - # artifact store and returns an opaque artifact_id. + # Deprecated no-op: caller paths were an arbitrary-write -> RCE vector. + # Never written; the server stores results in the artifact store instead. + output_path: Optional[str] = Field( + default=None, + deprecated=True, + description=( + "REMOVED in 0.9.0 and ignored - no file is written. Results are " + "stored server-side; fetch via GET /artifacts/{artifact_id}." + ), + ) class PDFRequest(BaseModel): url: str - # output_path removed (see ScreenshotRequest). + # output_path deprecated no-op (see ScreenshotRequest). + output_path: Optional[str] = Field( + default=None, + deprecated=True, + description=( + "REMOVED in 0.9.0 and ignored - no file is written. Results are " + "stored server-side; fetch via GET /artifacts/{artifact_id}." + ), + ) class JSEndpointRequest(BaseModel): diff --git a/deploy/docker/server.py b/deploy/docker/server.py index 410b8be38..cddf120a0 100644 --- a/deploy/docker/server.py +++ b/deploy/docker/server.py @@ -392,7 +392,7 @@ def _current_api_token() -> str: app.add_middleware( AuthGateMiddleware, token_provider=_current_api_token, - public_paths={HEALTH_PATH, "/token"}, + public_paths={HEALTH_PATH, "/token", "/"}, public_prefixes=_UI_PREFIXES, ) @@ -660,6 +660,32 @@ async def get_artifact(artifact_id: str, _td: Dict = Depends(token_dep)): # Screenshot endpoint +_OUTPUT_PATH_WARNING = ( + "output_path was removed in 0.9.0 and is ignored - no file was written. " + "The result is stored server-side; fetch it with an authenticated " + "GET /artifacts/{artifact_id}." +) + +_HOOKS_CODE_WARNING = ( + "Inline hook code (hooks.code) was removed in 0.9.0 and was NOT executed. " + "Use declarative hook actions instead (GET /hooks/info for the schema)." +) + + +def _reject_disabled_hooks(hooks) -> None: + """403 for any hooks payload while hooks are disabled. Legacy inline code + gets its own detail: enabling CRAWL4AI_HOOKS_ENABLED would not run it (the + feature was removed in 0.9.0), so the generic remedy would mislead.""" + if hooks.code: + raise HTTPException( + 403, + "Inline hook code (hooks.code) was removed in 0.9.0 and cannot be " + "enabled; it was not executed. Use declarative hook actions instead " + "(GET /hooks/info), which are additionally disabled on this server " + "(CRAWL4AI_HOOKS_ENABLED).", + ) + raise HTTPException(403, "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable.") + @app.post("/screenshot") @limiter.limit(config["rate_limiting"]["default_limit"]) @@ -675,6 +701,9 @@ async def generate_screenshot( sandboxed artifact store; the response includes an `artifact_id` and a `url` to fetch it. """ validate_url_scheme(body.url) + # model_dump, not attribute access: the field is marked deprecated and + # reading the attribute emits DeprecationWarning on every request. + legacy_output_path = body.model_dump(include={"output_path"}).get("output_path") crawler = None try: cfg = CrawlerRunConfig(screenshot=True, screenshot_wait_for=body.screenshot_wait_for, wait_for_images=body.wait_for_images) @@ -684,7 +713,10 @@ async def generate_screenshot( raise HTTPException(500, detail=results[0].error_message or "Crawl failed") screenshot_data = results[0].screenshot art = _store_artifact("png", base64.b64decode(screenshot_data)) - return {"success": True, "screenshot": screenshot_data, **art} + response = {"success": True, "screenshot": screenshot_data, **art} + if legacy_output_path: + response["warning"] = _OUTPUT_PATH_WARNING + return response except HTTPException: raise except Exception as e: @@ -710,6 +742,9 @@ async def generate_pdf( sandboxed artifact store; the response includes an `artifact_id` and a `url` to fetch it. """ validate_url_scheme(body.url) + # model_dump, not attribute access: the field is marked deprecated and + # reading the attribute emits DeprecationWarning on every request. + legacy_output_path = body.model_dump(include={"output_path"}).get("output_path") crawler = None try: cfg = CrawlerRunConfig(pdf=True) @@ -719,7 +754,10 @@ async def generate_pdf( raise HTTPException(500, detail=results[0].error_message or "Crawl failed") pdf_data = results[0].pdf art = _store_artifact("pdf", pdf_data) - return {"success": True, "pdf": base64.b64encode(pdf_data).decode(), **art} + response = {"success": True, "pdf": base64.b64encode(pdf_data).decode(), **art} + if legacy_output_path: + response["warning"] = _OUTPUT_PATH_WARNING + return response except HTTPException: raise except Exception as e: @@ -879,7 +917,7 @@ async def crawl( if not crawl_request.urls: raise HTTPException(400, "At least one URL required") if crawl_request.hooks and not HOOKS_ENABLED: - raise HTTPException(403, "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable.") + _reject_disabled_hooks(crawl_request.hooks) # Check whether it is a redirection for a streaming request try: crawler_config = CrawlerRunConfig.load( @@ -889,7 +927,7 @@ async def crawl( raise HTTPException(400, f"Rejected config: {e}") if crawler_config.stream: return await stream_process(crawl_request=crawl_request) - + # Prepare hooks config if provided hooks_config = None if crawl_request.hooks: @@ -897,7 +935,7 @@ async def crawl( 'hooks': crawl_request.hooks.hooks, 'timeout': crawl_request.hooks.timeout } - + results = await handle_crawl_request( urls=crawl_request.urls, browser_config=crawl_request.browser_config, @@ -906,6 +944,11 @@ async def crawl( hooks_config=hooks_config, crawler_configs=crawl_request.crawler_configs, ) + if crawl_request.hooks and crawl_request.hooks.code: + hooks_resp = results.setdefault("hooks", {"status": "ignored", "attached": []}) + if not crawl_request.hooks.hooks: + hooks_resp["status"] = "ignored" + hooks_resp["warning"] = _HOOKS_CODE_WARNING # check if all of the results are not successful if all(not result["success"] for result in results["results"]): raise HTTPException(500, f"Crawl request failed: {results['results'][0]['error_message']}") @@ -922,12 +965,12 @@ async def crawl_stream( if not crawl_request.urls: raise HTTPException(400, "At least one URL required") if crawl_request.hooks and not HOOKS_ENABLED: - raise HTTPException(403, "Hooks are disabled. Set CRAWL4AI_HOOKS_ENABLED=true to enable.") + _reject_disabled_hooks(crawl_request.hooks) return await stream_process(crawl_request=crawl_request) async def stream_process(crawl_request: CrawlRequestWithHooks): - + # Prepare hooks config if provided# Prepare hooks config if provided hooks_config = None if crawl_request.hooks: @@ -953,6 +996,8 @@ async def stream_process(crawl_request: CrawlRequestWithHooks): if hooks_info: import json headers["X-Hooks-Status"] = json.dumps(hooks_info['status']['status']) + if crawl_request.hooks and crawl_request.hooks.code: + headers["X-Hooks-Warning"] = _HOOKS_CODE_WARNING return StreamingResponse( stream_results(crawler, gen), diff --git a/deploy/docker/static/playground/index.html b/deploy/docker/static/playground/index.html index e88c281a0..ba368b768 100644 --- a/deploy/docker/static/playground/index.html +++ b/deploy/docker/static/playground/index.html @@ -617,6 +617,25 @@

🔥 Stress Test

} } + // Build a useful error message from a failed HTTP response + function httpErrorMessage(response, data) { + let detail = data && (data.detail || data.error); + // FastAPI 422s send detail as an array of error objects; other + // non-strings would render as [object Object]. + if (Array.isArray(detail)) { + detail = detail.map(e => e && e.msg ? `${(e.loc || []).join('.')}: ${e.msg}` : JSON.stringify(e)).join('; '); + } else if (detail && typeof detail !== 'string') { + detail = JSON.stringify(detail); + } + let msg = detail || `HTTP ${response.status}`; + if (response.status === 401) { + msg += getToken() + ? ' — token rejected; check the API token in the token bar (top right)' + : ' — set your API token in the token bar (top right)'; + } + return msg; + } + // Generate code snippets function generateSnippets(api, payload, method = 'POST') { // Python snippet @@ -747,11 +766,11 @@

🔥 Stress Test

method: 'GET', headers: { 'Accept': 'application/json' } }); - responseData = await response.json(); + responseData = await response.json().catch(() => ({})); const time = Math.round(performance.now() - startTime); if (!response.ok) { updateStatus('error', time); - throw new Error(responseData.error || 'Request failed'); + throw new Error(httpErrorMessage(response, responseData)); } updateStatus('success', time); document.querySelector('#response-content code').textContent = JSON.stringify(responseData, null, 2); @@ -765,6 +784,12 @@

🔥 Stress Test

body: JSON.stringify(payload) }); + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + updateStatus('error', Math.round(performance.now() - startTime)); + throw new Error(httpErrorMessage(response, errData)); + } + const reader = response.body.getReader(); let text = ''; let maxMemory = 0; @@ -804,12 +829,12 @@

🔥 Stress Test

body: JSON.stringify(payload) }); - responseData = await response.json(); + responseData = await response.json().catch(() => ({})); const time = Math.round(performance.now() - startTime); if (!response.ok) { updateStatus('error', time); - throw new Error(responseData.error || 'Request failed'); + throw new Error(httpErrorMessage(response, responseData)); } updateStatus( diff --git a/deploy/docker/tests/requirements.txt b/deploy/docker/tests/requirements.txt index 5f7a842fe..b0206020f 100644 --- a/deploy/docker/tests/requirements.txt +++ b/deploy/docker/tests/requirements.txt @@ -1,2 +1,3 @@ httpx>=0.25.0 docker>=7.0.0 +pyyaml>=6.0 diff --git a/deploy/docker/tests/test_legacy_compat.py b/deploy/docker/tests/test_legacy_compat.py new file mode 100644 index 000000000..2a76d6e27 --- /dev/null +++ b/deploy/docker/tests/test_legacy_compat.py @@ -0,0 +1,222 @@ +""" +Behavioral tests for 0.9.x legacy-compatibility handling: + + * root redirect - "/" is public and redirects to /playground instead of + dying in the auth gate with a bare 401; /monitor and the + data routes stay gated. + * output_path - /screenshot and /pdf still accept the 0.8.x output_path + field but return a warning saying no file was written, + instead of silently dropping it. + * legacy hooks - hooks.code (removed 0.8.x inline Python) is captured, + never executed, and reported as status "ignored" with a + warning when hooks are enabled; any hooks payload is + still refused (403) while hooks are disabled. + +(The compose PID-cap check lives in test_security_container_posture.py.) + +These exercise the running app via TestClient (no browser / Redis needed); +crawl internals are stubbed where a handler would otherwise need a browser. +""" + +import base64 +from types import SimpleNamespace + +import pytest + +from auth import create_access_token # noqa: E402 + + +def _bearer() -> dict: + return {"Authorization": f"Bearer {create_access_token({'sub': 'user@x.com'}, scope='data')}"} + + +# ───────────────────────── root redirect ───────────────────────── + + +class TestRootRedirect: + def test_root_is_public_and_redirects_to_playground(self, stock_client): + r = stock_client.get("/", follow_redirects=False) + assert r.status_code in (302, 307), ( + f"GET / returned {r.status_code}; expected a redirect. The auth " + f"gate must allow the exact path '/' so the redirect route runs." + ) + assert r.headers["location"] == "/playground" + + def test_monitor_and_data_routes_stay_gated(self, stock_client): + # /monitor must not serve content without a token; a future + # /monitor -> /dashboard redirect is fine (the target is UI-public), + # so accept 401 or a redirect, never 200. + r = stock_client.get("/monitor", follow_redirects=False) + assert r.status_code in (401, 302, 307, 308) + assert stock_client.get("/monitor/health").status_code == 401 + assert stock_client.post("/crawl", json={"urls": ["https://x"]}).status_code == 401 + + +# ───────────────────────── output_path warning ───────────────────────── + + +@pytest.fixture +def stub_crawler(server_module, monkeypatch): + """Stub the crawler pool + artifact store so /screenshot and /pdf run + without a browser. Returns the fake artifact dict for assertions.""" + art = {"artifact_id": "a1", "url": "/artifacts/a1", "mime": "x", "size": 1} + png_b64 = base64.b64encode(b"fake-png").decode() + + fake_result = SimpleNamespace(success=True, screenshot=png_b64, pdf=b"fake-pdf") + + class _FakeCrawler: + async def arun(self, url, config): + return [fake_result] + + async def fake_get_crawler(cfg): + return _FakeCrawler() + + async def fake_release_crawler(crawler): + pass + + monkeypatch.setattr(server_module, "get_crawler", fake_get_crawler) + monkeypatch.setattr(server_module, "release_crawler", fake_release_crawler) + monkeypatch.setattr(server_module, "_store_artifact", lambda kind, data: dict(art)) + return art + + +class TestOutputPathWarning: + @pytest.mark.parametrize("endpoint,payload_key", [("/screenshot", "screenshot"), ("/pdf", "pdf")]) + def test_output_path_accepted_with_warning(self, stock_client, stub_crawler, endpoint, payload_key): + r = stock_client.post( + endpoint, + json={"url": "https://example.com", "output_path": "/tmp/x.bin"}, + headers=_bearer(), + ) + assert r.status_code == 200 + body = r.json() + assert body["success"] is True + assert "warning" in body, "output_path must not be silently dropped" + assert "no file was written" in body["warning"] + assert body["artifact_id"] == stub_crawler["artifact_id"] + + @pytest.mark.parametrize("endpoint", ["/screenshot", "/pdf"]) + def test_no_warning_without_output_path(self, stock_client, stub_crawler, endpoint): + r = stock_client.post(endpoint, json={"url": "https://example.com"}, headers=_bearer()) + assert r.status_code == 200 + assert "warning" not in r.json() + + @pytest.mark.parametrize("endpoint", ["/screenshot", "/pdf"]) + def test_output_path_is_never_written(self, stock_client, stub_crawler, endpoint, tmp_path): + """Security tripwire for the 0.8.x arbitrary-write vuln: the handler + runs for real here (only crawler/artifact store are stubbed), so any + reintroduced write of body.output_path creates this file and fails.""" + target = tmp_path / "out.bin" + r = stock_client.post( + endpoint, json={"url": "https://example.com", "output_path": str(target)}, + headers=_bearer(), + ) + assert r.status_code == 200 + assert not target.exists(), "output_path must never be written to disk" + + +# ───────────────────────── legacy hooks.code ───────────────────────── + +LEGACY_HOOKS = {"code": {"before_goto": "async def hook(p, c, u, **kw): return p"}} +DECLARATIVE_HOOKS = {"hooks": [{"action": "scroll_to_bottom", "params": {"max_steps": 2}}]} + + +class TestLegacyHookCode: + def test_hook_config_captures_code_field(self): + """The legacy field must be parsed (not dropped) so it can be reported.""" + from schemas import CrawlRequestWithHooks + + req = CrawlRequestWithHooks(urls=["https://x"], hooks=LEGACY_HOOKS) + assert req.hooks.code == LEGACY_HOOKS["code"] + assert req.hooks.hooks == [] + + @pytest.mark.parametrize("code", [ + "def hook(): ...", # bare string + {"before_goto": {"nested": "dict"}}, # non-string values + ["a", "list"], # wrong container entirely + ]) + def test_hook_code_accepts_any_legacy_shape(self, code): + """The 0.8.x wire shape was never pinned; a 422 on a field we only + report about would be worse than the silent drop it replaces.""" + from schemas import CrawlRequestWithHooks + + req = CrawlRequestWithHooks(urls=["https://x"], hooks={"code": code}) + assert req.hooks.code == code + + # Any hooks payload is refused while hooks are disabled, but the detail + # must not mislead legacy callers: enabling the flag would not run + # hooks.code (removed in 0.9.0), so payloads carrying it get a removal + # message instead of the generic 'set CRAWL4AI_HOOKS_ENABLED' hint. + @pytest.mark.parametrize("hooks_payload,expected_detail", [ + (LEGACY_HOOKS, "removed in 0.9.0"), # code-only + ({**DECLARATIVE_HOOKS, **LEGACY_HOOKS}, "removed in 0.9.0"), # mixed + (DECLARATIVE_HOOKS, "Set CRAWL4AI_HOOKS_ENABLED=true"), # declarative-only + ]) + def test_any_hooks_payload_403_when_disabled( + self, server_module, monkeypatch, stock_client, hooks_payload, expected_detail + ): + monkeypatch.setattr(server_module, "HOOKS_ENABLED", False) + r = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"], "hooks": hooks_payload}, + headers=_bearer(), + ) + assert r.status_code == 403 + assert expected_detail in r.json()["detail"] + + def _stub_crawl(self, server_module, monkeypatch, results): + async def fake_handle_crawl_request(**kwargs): + return dict(results) + + monkeypatch.setattr(server_module, "handle_crawl_request", fake_handle_crawl_request) + + def test_legacy_code_warned_and_ignored_when_enabled(self, server_module, monkeypatch, stock_client): + monkeypatch.setattr(server_module, "HOOKS_ENABLED", True) + # Empty declarative specs -> api.py reports a vacuous success + self._stub_crawl( + server_module, monkeypatch, + {"success": True, "results": [{"success": True}], + "hooks": {"status": "success", "attached": []}}, + ) + r = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"], "hooks": LEGACY_HOOKS}, + headers=_bearer(), + ) + assert r.status_code == 200 + hooks = r.json()["hooks"] + assert hooks["status"] == "ignored", "vacuous 'success' must be rewritten" + assert hooks["attached"] == [] + assert "NOT executed" in hooks["warning"] + + def test_mixed_request_keeps_declarative_status_and_warns(self, server_module, monkeypatch, stock_client): + monkeypatch.setattr(server_module, "HOOKS_ENABLED", True) + self._stub_crawl( + server_module, monkeypatch, + {"success": True, "results": [{"success": True}], + "hooks": {"status": "success", "attached": ["before_retrieve_html"]}}, + ) + r = stock_client.post( + "/crawl", + json={"urls": ["https://example.com"], + "hooks": {**DECLARATIVE_HOOKS, **LEGACY_HOOKS}}, + headers=_bearer(), + ) + assert r.status_code == 200 + hooks = r.json()["hooks"] + assert hooks["status"] == "success", "real declarative execution must not be relabeled" + assert hooks["attached"] == ["before_retrieve_html"] + assert "NOT executed" in hooks["warning"] + + def test_no_hooks_response_untouched(self, server_module, monkeypatch, stock_client): + self._stub_crawl( + server_module, monkeypatch, + {"success": True, "results": [{"success": True}]}, + ) + r = stock_client.post("/crawl", json={"urls": ["https://example.com"]}, headers=_bearer()) + assert r.status_code == 200 + assert "hooks" not in r.json() + + +# The compose PID-cap check lives in +# test_security_container_posture.py::test_pids_limit (YAML-parsed). diff --git a/deploy/docker/tests/test_security_2026_04.py b/deploy/docker/tests/test_security_2026_04.py index 4109de046..e015274b6 100644 --- a/deploy/docker/tests/test_security_2026_04.py +++ b/deploy/docker/tests/test_security_2026_04.py @@ -82,23 +82,29 @@ def validate_webhook_url(url): # ============================================================================ class TestOutputPathRemoved(unittest.TestCase): - """output_path is gone; the server owns paths via the artifact store. + """output_path never reaches the filesystem; the server owns paths via the + artifact store. The old string-only validate_output_path was bypassable (symlink/TOCTOU, sibling-prefix '...-evil') -> arbitrary write -> RCE. The fix is to never - accept a caller path at all. Behavioral artifact-store coverage (O_NOFOLLOW, - O_EXCL, hex id, TTL, quota) lives in test_security_artifact_store.py. + use a caller path: output_path exists only as a deprecated no-op for 0.8.x + compatibility and must stay that way. Artifact-store coverage lives in + test_security_artifact_store.py; warning behavior in test_legacy_compat.py. """ - def test_screenshot_request_has_no_output_path(self): + def test_screenshot_output_path_is_deprecated_noop(self): sys.path.insert(0, DEPLOY_DIR) from schemas import ScreenshotRequest - self.assertNotIn("output_path", ScreenshotRequest.model_fields) + field = ScreenshotRequest.model_fields["output_path"] + self.assertTrue(field.deprecated, "output_path must be marked deprecated") + self.assertIsNone(field.default) - def test_pdf_request_has_no_output_path(self): + def test_pdf_output_path_is_deprecated_noop(self): sys.path.insert(0, DEPLOY_DIR) from schemas import PDFRequest - self.assertNotIn("output_path", PDFRequest.model_fields) + field = PDFRequest.model_fields["output_path"] + self.assertTrue(field.deprecated, "output_path must be marked deprecated") + self.assertIsNone(field.default) def test_validate_output_path_deleted(self): sys.path.insert(0, DEPLOY_DIR) @@ -110,18 +116,21 @@ def test_validate_output_path_deleted(self): class TestPydanticPathValidator(unittest.TestCase): - """output_path (and its traversal validator) are gone entirely. + """The traversal validator is gone entirely. Traversal rejection used to be the mitigation; the real fix is that no - caller path is accepted at all, so there is nothing to traverse. The - sandboxed artifact store owns all paths now. + caller path is ever *used*, so there is nothing to traverse. output_path + survives only as a deprecated no-op field (see TestOutputPathRemoved); the + sandboxed artifact store owns all paths. """ - def test_no_output_path_field_on_request_models(self): + def test_output_path_is_inert_on_request_models(self): sys.path.insert(0, DEPLOY_DIR) from schemas import ScreenshotRequest, PDFRequest - self.assertNotIn("output_path", ScreenshotRequest.model_fields) - self.assertNotIn("output_path", PDFRequest.model_fields) + for model in (ScreenshotRequest, PDFRequest): + field = model.model_fields["output_path"] + self.assertTrue(field.deprecated) + self.assertIsNone(field.default) def test_traversal_validator_removed(self): # No reject_traversal validator should remain registered on the models. diff --git a/deploy/docker/tests/test_security_container_posture.py b/deploy/docker/tests/test_security_container_posture.py index 3399d6bd1..eb5df6153 100644 --- a/deploy/docker/tests/test_security_container_posture.py +++ b/deploy/docker/tests/test_security_container_posture.py @@ -104,7 +104,14 @@ def test_no_host_dev_shm_bind(self, compose): assert "shm_size" in compose def test_pids_limit(self, compose): - assert "pids_limit" in compose + # Parse, don't grep: a raw-text search matched the word "pids_limit" + # inside a comment and guarded nothing. The cap lives under + # deploy.resources.limits (not pids_limit) for Compose v5 compatibility. + import yaml + + base = yaml.safe_load(compose)["x-base-config"] + assert "pids_limit" not in base + assert base["deploy"]["resources"]["limits"]["pids"] == 512 def test_read_only_runtime_tmpfs_are_appuser_owned(self, compose): assert "/var/lib/redis:uid=999,gid=999,mode=0700" in compose diff --git a/docker-compose.yml b/docker-compose.yml index 1cd87ad93..5939b3321 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,6 @@ x-base-config: &base-config - ALL security_opt: - no-new-privileges:true - pids_limit: 512 # Read-only root filesystem; only these paths are writable (tmpfs). read_only: true tmpfs: @@ -39,6 +38,9 @@ x-base-config: &base-config resources: limits: memory: 4G + # PID cap; expressed here (not as pids_limit) so the file stays valid + # on Compose v5+, which rejects pids_limit alongside a limits block. + pids: 512 reservations: memory: 1G restart: unless-stopped