From 351ffdb3da772c2995ba6ab85ab9e0ebb45165be Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:17:30 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL/HIGH]=20Fix=20Information=20Disclosure=20/=20Secret=20Leakag?= =?UTF-8?q?e=20in=20Sandboxing=20Logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added log redaction for standard output and error captured during the subprocess execution inside the `sandboxed_verify.py` and `sandboxed_web_e2e.py` wrappers. Ensured that tokens are correctly scrubbed before they reach standard output. --- scripts/ci/sandboxed_verify.py | 15 +++++++++++---- scripts/ci/sandboxed_web_e2e.py | 5 +++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..670fd2d65 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -14,6 +14,11 @@ from collections.abc import Sequence from pathlib import Path +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import redact_text + DEFAULT_IGNORE = ( ".git", @@ -169,8 +174,10 @@ def timeout_output_text(value: str | bytes | None) -> str: if value is None: return "" if isinstance(value, bytes): - return value.decode(errors="replace") - return value + text = value.decode(errors="replace") + else: + text = value + return redact_text(text) def emit_result( @@ -219,9 +226,9 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_command(args.command, copied_repo, env, args.timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode except subprocess.TimeoutExpired as exc: stdout = timeout_output_text(exc.stdout) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..4d27ba037 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import sandboxed_verify +from scripts.ci.redact_sensitive_log import redact_text RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT" @@ -232,9 +233,9 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode return exit_code except subprocess.TimeoutExpired as exc: From dc146b0c813c3251301b96a853b9a8de3ebf2d55 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:55:24 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=B0=8F=20GitHub=20Models=20Brownout=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EC=95=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 샌드박스 실행 시 캡처되는 출력 스트림에 누락된 시크릿 마스킹 처리를 추가하여 정보 노출(Information Disclosure) 취약점을 해결했습니다. 또한 Strix 스캔 폴백에서 발생하는 간헐적인 410 (GitHub Models retirement brownout) 오류를 올바르게 예외 처리하여 전체 스캔을 실패시키지 않도록 개선했습니다. 이 변경사항은 Sentinel 메모리와 CHANGELOG.md에도 기록되었습니다. --- .jules/sentinel.md | 4 ++++ CHANGELOG.md | 3 +++ scripts/ci/strix_quick_gate.sh | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index be2dfa4bb..05c48f126 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -35,3 +35,7 @@ **Vulnerability:** Command Injection **Learning:** Fixing a `shell=True` vulnerability by replacing it with `shell=False` and wrapping the command string in `["/bin/bash", "-lc", command]` is incomplete and still leaves the code vulnerable to shell injection. It acts as security theater, as it misleads linters while executing untrusted input via the bash wrapper. The vulnerability was still present in `sandboxed_web_e2e.py`. **Prevention:** Remove `/bin/bash` wrapper from `subprocess` calls in CI scripts. Always use `shlex.split(command)` to safely parse strings into a list of arguments and pass the list directly to `subprocess.Popen` or `subprocess.run`. +## 2026-08-08 - Prevent 410 GitHub Models Brownouts from Failing CI +**Vulnerability:** Workflow CI Security Bypass / Log-Only Severity Markers +**Learning:** GitHub Models is retiring certain models and introduces intermittent 410 (Gone) brownouts for them. `strix_quick_gate.sh` did not identify `Error code: 410` and `github_models_retirement_brownout` as an unavailable provider model error, meaning Strix treated the 410 output as anomalous/failed output instead of seamlessly falling back to the next model. +**Prevention:** Updated `is_github_models_unavailable_model_error` to catch 410 responses and the explicit `github_models_retirement_brownout` identifier, preserving the correct fallback-exhaustion logic. diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..84035de11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Strix 스캔 게이트의 폴백(fallback) 로직에서 GitHub 모델 종료에 따른 410 (brownout) 오류를 정상적인 폴백 트리거로 인식하도록 수정했습니다. +- 샌드박스 검증 스크립트(`sandboxed_verify.py`, `sandboxed_web_e2e.py`)의 하위 프로세스 실행 시 캡처된 표준 출력과 표준 에러에 로그 데이터 마스킹(redaction) 로직을 추가하여 민감한 정보의 노출을 방지했습니다. + - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 0f37f3460..8000b0ec2 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2816,7 +2816,7 @@ is_github_models_unavailable_model_error() { return 0 fi - if grep -Eiq '(PermissionDeniedError|Error code:[[:space:]]*403|(^|[^0-9])403([^0-9]|$))' "$STRIX_LOG" && + if grep -Eiq '(PermissionDeniedError|Error code:[[:space:]]*403|(^|[^0-9])403([^0-9]|$)|Error code:[[:space:]]*410|github_models_retirement_brownout)' "$STRIX_LOG" && grep -Eiq '(LLM CONNECTION FAILED|Could not establish connection to the language model)' "$STRIX_LOG" && grep -Eiq '(models\.github\.ai|GitHub Models|openai|OpenAIException)' "$STRIX_LOG"; then return 0 From 8caa23d939081ff51c96a507a190a7ae7026e706 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:30:15 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL/HIGH]=20Fix=20Information=20Disclosure=20/=20Secret=20Leakag?= =?UTF-8?q?e=20in=20Sandboxing=20Logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added log redaction for standard output and error captured during the subprocess execution inside the `sandboxed_verify.py` and `sandboxed_web_e2e.py` wrappers. Ensured that tokens are correctly scrubbed before they reach standard output. Also added targeted test cases to confirm byte output redactions on `TimeoutExpired`, ensuring robust failure path protection without relying on module side effects. --- .jules/sentinel.md | 4 --- CHANGELOG.md | 4 +-- scripts/ci/sandboxed_verify.py | 2 +- scripts/ci/sandboxed_web_e2e.py | 2 +- scripts/ci/strix_quick_gate.sh | 2 +- tests/test_sandboxed_verify.py | 46 +++++++++++++++++++++++++++++++++ tests/test_sandboxed_web_e2e.py | 41 +++++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 05c48f126..be2dfa4bb 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -35,7 +35,3 @@ **Vulnerability:** Command Injection **Learning:** Fixing a `shell=True` vulnerability by replacing it with `shell=False` and wrapping the command string in `["/bin/bash", "-lc", command]` is incomplete and still leaves the code vulnerable to shell injection. It acts as security theater, as it misleads linters while executing untrusted input via the bash wrapper. The vulnerability was still present in `sandboxed_web_e2e.py`. **Prevention:** Remove `/bin/bash` wrapper from `subprocess` calls in CI scripts. Always use `shlex.split(command)` to safely parse strings into a list of arguments and pass the list directly to `subprocess.Popen` or `subprocess.run`. -## 2026-08-08 - Prevent 410 GitHub Models Brownouts from Failing CI -**Vulnerability:** Workflow CI Security Bypass / Log-Only Severity Markers -**Learning:** GitHub Models is retiring certain models and introduces intermittent 410 (Gone) brownouts for them. `strix_quick_gate.sh` did not identify `Error code: 410` and `github_models_retirement_brownout` as an unavailable provider model error, meaning Strix treated the 410 output as anomalous/failed output instead of seamlessly falling back to the next model. -**Prevention:** Updated `is_github_models_unavailable_model_error` to catch 410 responses and the explicit `github_models_retirement_brownout` identifier, preserving the correct fallback-exhaustion logic. diff --git a/CHANGELOG.md b/CHANGELOG.md index 84035de11..b9643ea59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Strix 스캔 게이트의 폴백(fallback) 로직에서 GitHub 모델 종료에 따른 410 (brownout) 오류를 정상적인 폴백 트리거로 인식하도록 수정했습니다. -- 샌드박스 검증 스크립트(`sandboxed_verify.py`, `sandboxed_web_e2e.py`)의 하위 프로세스 실행 시 캡처된 표준 출력과 표준 에러에 로그 데이터 마스킹(redaction) 로직을 추가하여 민감한 정보의 노출을 방지했습니다. - +- Added log redaction for standard output and error captured during subprocess execution in `sandboxed_verify.py` and `sandboxed_web_e2e.py` wrappers. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index 670fd2d65..8395d0e23 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -14,7 +14,7 @@ from collections.abc import Sequence from pathlib import Path -if __package__ in (None, ""): +if __package__ in (None, ""): # pragma: no cover sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci.redact_sensitive_log import redact_text diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 4d27ba037..2f2ecdb7e 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from pathlib import Path -if __package__ in (None, ""): +if __package__ in (None, ""): # pragma: no cover sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import sandboxed_verify diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 8000b0ec2..0f37f3460 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2816,7 +2816,7 @@ is_github_models_unavailable_model_error() { return 0 fi - if grep -Eiq '(PermissionDeniedError|Error code:[[:space:]]*403|(^|[^0-9])403([^0-9]|$)|Error code:[[:space:]]*410|github_models_retirement_brownout)' "$STRIX_LOG" && + if grep -Eiq '(PermissionDeniedError|Error code:[[:space:]]*403|(^|[^0-9])403([^0-9]|$))' "$STRIX_LOG" && grep -Eiq '(LLM CONNECTION FAILED|Could not establish connection to the language model)' "$STRIX_LOG" && grep -Eiq '(models\.github\.ai|GitHub Models|openai|OpenAIException)' "$STRIX_LOG"; then return 0 diff --git a/tests/test_sandboxed_verify.py b/tests/test_sandboxed_verify.py index c711f3489..f69784341 100644 --- a/tests/test_sandboxed_verify.py +++ b/tests/test_sandboxed_verify.py @@ -1,3 +1,4 @@ +import subprocess import json import runpy import shutil @@ -198,3 +199,48 @@ def test_module_main_entrypoint(monkeypatch, tmp_path): if module is not None: sys.modules["scripts.ci.sandboxed_verify"] = module assert exc_info.value.code == 0 + +def test_sandboxed_verify_redacts_stdout_and_stderr(monkeypatch, tmp_path, capsys): + """The wrapper redacts sensitive tokens from completed process stdout and stderr.""" + class CompletedProcess: + def __init__(self, stdout, stderr, returncode): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + def run_command(command, cwd, env, timeout): + return CompletedProcess( + "Here is my api_key: 'ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678'", + "Failed because of password: mysecretpassword123", + 0 + ) + + monkeypatch.setattr(sandboxed_verify, "run_command", run_command) + + sandboxed_verify.main(["echo", "test"]) + + out, err = capsys.readouterr() + assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678" not in out + assert "[REDACTED]" in out + assert "mysecretpassword123" not in err + assert "[REDACTED]" in err + +def test_timeout_redacts_bytes_output(monkeypatch, tmp_path, capsys): + """The wrapper redacts sensitive tokens from timeout stdout and stderr bytes.""" + def run_command(command, cwd, env, timeout): + raise subprocess.TimeoutExpired( + cmd="mock", + timeout=10, + output=b"Timeout with token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678", + stderr=b"Timeout error with password: mysecretpassword123" + ) + + monkeypatch.setattr(sandboxed_verify, "run_command", run_command) + + sandboxed_verify.main(["echo", "test"]) + + out, err = capsys.readouterr() + assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678" not in out + assert "[REDACTED]" in out + assert "mysecretpassword123" not in err + assert "[REDACTED]" in err diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..72dde31ff 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -1,3 +1,4 @@ +import subprocess import json import os import runpy @@ -598,3 +599,43 @@ def test_module_import_and_main_entrypoint(monkeypatch, tmp_path): if module is not None: sys.modules["scripts.ci.sandboxed_web_e2e"] = module assert exc_info.value.code == 0 + +def test_sandboxed_web_e2e_redacts_stdout_and_stderr(monkeypatch, tmp_path, capsys): + """The E2E wrapper redacts sensitive tokens from completed process stdout and stderr.""" + class CompletedProcess: + def __init__(self, stdout, stderr, returncode): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + def run_shell(command, cwd, env, timeout): + return CompletedProcess( + "Found token: 'github_pat_11AAAAAAA000000000000000000000000000000000000000000000000000000000000000000000'", + "Error: could not login with session_key: 123456", + 1 + ) + + def mock_wait_for_url(*args): + return True + + def mock_start_service(*args): + class Service: + label = "mock" + command = "mock" + process = type("Proc", (), {"poll": lambda self: None, "pid": 123, "wait": lambda self, timeout: None})() + log_path = tmp_path / "mock.log" + def __init__(self): + self.log_path.touch() + return Service() + + monkeypatch.setattr(sandboxed_web_e2e, "run_shell", run_shell) + monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", mock_wait_for_url) + monkeypatch.setattr(sandboxed_web_e2e, "start_service", mock_start_service) + + sandboxed_web_e2e.main(["--backend-cmd", "mock", "--frontend-cmd", "mock", "--e2e-cmd", "mock"]) + + out, err = capsys.readouterr() + assert "github_pat_11AAAAAAA000000000000000000000000000000000000000000000000000000000000000000000" not in out + assert "[REDACTED]" in out + assert "session_key: 123456" not in err + assert "[REDACTED]" in err From 1010bf4a14373c14f3cb7debfe22f77149ad60b0 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:56:07 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL/HIGH]=20Fix=20Information=20Disclosure=20and=20SSRF=20in=20S?= =?UTF-8?q?andboxing=20Logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added log redaction for standard output and error captured during the subprocess execution inside the `sandboxed_verify.py` and `sandboxed_web_e2e.py` wrappers. Ensured that tokens are correctly scrubbed before they reach standard output. Also added targeted test cases to confirm byte output redactions on `TimeoutExpired`, ensuring robust failure path protection without relying on module side effects. Additionally fixed an SSRF vulnerability in `sandboxed_web_e2e.py` readiness polling by strictly enforcing localhost and loopback targets instead of merely relying on HTTP/HTTPS prefix checks. --- .jules/sentinel.md | 4 ++++ CHANGELOG.md | 1 + scripts/ci/sandboxed_web_e2e.py | 6 ++++++ tests/test_sandboxed_web_e2e.py | 11 +++++++++++ 4 files changed, 22 insertions(+) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index be2dfa4bb..22096180a 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -35,3 +35,7 @@ **Vulnerability:** Command Injection **Learning:** Fixing a `shell=True` vulnerability by replacing it with `shell=False` and wrapping the command string in `["/bin/bash", "-lc", command]` is incomplete and still leaves the code vulnerable to shell injection. It acts as security theater, as it misleads linters while executing untrusted input via the bash wrapper. The vulnerability was still present in `sandboxed_web_e2e.py`. **Prevention:** Remove `/bin/bash` wrapper from `subprocess` calls in CI scripts. Always use `shlex.split(command)` to safely parse strings into a list of arguments and pass the list directly to `subprocess.Popen` or `subprocess.run`. +## 2026-08-08 - Prevent SSRF in Web E2E Readiness Polling +**Vulnerability:** Server-Side Request Forgery (SSRF) / Local File Inclusion +**Learning:** Checking that a user-provided `--backend-ready-url` or `--frontend-ready-url` starts with `http://` or `https://` is insufficient if the script blindly requests the URL. This allows attackers to proxy requests through CI to internal endpoints or metadata services (e.g. `169.254.169.254`). +**Prevention:** Strictly parse the URL using `urllib.parse.urlparse` and ensure the hostname belongs to an explicitly allowed loopback interface (e.g., `127.0.0.1`, `localhost`, `::1`). diff --git a/CHANGELOG.md b/CHANGELOG.md index b9643ea59..510090834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Replaced prefix-only HTTP/HTTPS readiness validation with robust SSRF protection in the `sandboxed_web_e2e.py` E2E test harness. - Added log redaction for standard output and error captured during subprocess execution in `sandboxed_verify.py` and `sandboxed_web_e2e.py` wrappers. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index 2f2ecdb7e..75b12261d 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -13,6 +13,7 @@ import tempfile import time import urllib.error +import urllib.parse import urllib.request from collections.abc import Sequence from dataclasses import dataclass @@ -122,6 +123,11 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: return True if not (url.startswith("http://") or url.startswith("https://")): raise ValueError(f"URL must start with http:// or https://, got: {url}") + + parsed = urllib.parse.urlparse(url) + if parsed.hostname not in ("127.0.0.1", "localhost", "::1"): + raise ValueError(f"Readiness URL must use localhost or loopback IP, got: {url}") + deadline = time.monotonic() + timeout opener = urllib.request.build_opener(NoRedirectHandler()) while time.monotonic() < deadline: diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 72dde31ff..7c99a9eef 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -639,3 +639,14 @@ def __init__(self): assert "[REDACTED]" in out assert "session_key: 123456" not in err assert "[REDACTED]" in err +def test_wait_for_url_rejects_external_hosts(monkeypatch, tmp_path): + class RunningProcess: + def poll(self): + return None + service = sandboxed_web_e2e.Service("web", "serve", RunningProcess(), tmp_path / "mock.log") + + with pytest.raises(ValueError, match="Readiness URL must use localhost or loopback IP"): + sandboxed_web_e2e.wait_for_url("http://169.254.169.254/latest/meta-data/", 10, service) + + with pytest.raises(ValueError, match="Readiness URL must use localhost or loopback IP"): + sandboxed_web_e2e.wait_for_url("https://example.com", 10, service)