fix(security): confine teardown paths and avoid interpolating exception data in logs

- routes/gallery/gallery_routes.py: use stable diagnostic log message without
  interpolating raw exception, resolver details, tokens, or destination strings.
- tests/e2e/teardown.js: remove environment-variable path overrides from production
  code, strictly confine test options and temp directories, and prevent arbitrary
  directory cleanup.
- Add regression tests covering non-leakage of synthetic tokens/queries/exceptions
  into logs and responses, and path confinement during teardown.
This commit is contained in:
Alexandre Teixeira
2026-10-28 21:14:43 +00:00
parent de8f151632
commit 6d102a213f
4 changed files with 184 additions and 82 deletions
+113 -64
View File
@@ -1,12 +1,14 @@
"""Regression tests for Playwright global teardown security hardening (CodeQL #1202).
"""Regression tests for Playwright global teardown security hardening (CodeQL #1202 & Filesystem Safety).
Validates that:
1. Environment variables and paths passed to teardown cannot trigger shell command execution.
1. Shell command execution is prevented (execFileSync with shell: false and separate argument arrays).
2. Synthetic command substitutions and shell metacharacters fail safely without invoking a shell.
3. Paths containing spaces and non-standard characters are preserved without word-splitting.
4. Process errors (missing executable, nonzero exit, timeout) fail closed by purging artifacts.
5. Secure default tracing (unconditional CI suppression and local default purging) remains enforced.
6. Opt-in local runs execute the sanitizer and preserve sanitized archives.
7. Filesystem confinement: teardown ignores arbitrary directory environment variables and confines
cleanup to strictly validated test-owned locations.
"""
import json
@@ -23,13 +25,16 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
TEARDOWN_PATH = REPO_ROOT / "tests" / "e2e" / "teardown.js"
def _run_teardown(env: dict) -> subprocess.CompletedProcess:
"""Run teardown.js in an isolated Node process with custom environment."""
def _run_teardown(env: dict = None, options: dict = None) -> subprocess.CompletedProcess:
"""Run teardown.js in an isolated Node process with custom environment and options."""
full_env = os.environ.copy()
full_env.update(env)
if env:
full_env.update(env)
opts_json = json.dumps(options or {})
script = (
"const teardown = require('./tests/e2e/teardown.js');\n"
"teardown().then(() => process.exit(0)).catch((err) => {\n"
f"const opts = {opts_json};\n"
"teardown(opts).then(() => process.exit(0)).catch((err) => {\n"
" console.error(err);\n"
" process.exit(1);\n"
"});\n"
@@ -52,12 +57,14 @@ def test_teardown_unconditional_ci_suppression(tmp_path):
(results_dir / "failure.png").write_bytes(b"\x89PNG sensitive screenshot")
(results_dir / "temp.tmp").write_text("in-progress trace data")
res = _run_teardown({
"CI": "true",
"GITHUB_ACTIONS": "true",
"PLAYWRIGHT_ENABLE_TRACE": "1", # Even with opt-in, CI must purge unconditionally
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "true",
"GITHUB_ACTIONS": "true",
"PLAYWRIGHT_ENABLE_TRACE": "1", # Even with opt-in, CI must purge unconditionally
},
options={"resultsDir": str(results_dir)},
)
assert res.returncode == 0
# All sensitive artifacts must be purged
assert not (results_dir / "trace.zip").exists()
@@ -75,12 +82,14 @@ def test_teardown_local_default_purges_without_opt_in(tmp_path):
(results_dir / "failure.png").write_bytes(b"\x89PNG")
(results_dir / "temp.tmp").write_text("temp")
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "0", # No opt-in
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "0", # No opt-in
},
options={"resultsDir": str(results_dir)},
)
assert res.returncode == 0
assert not (results_dir / "trace.zip").exists()
assert not (results_dir / "session.har").exists()
@@ -97,13 +106,15 @@ def test_teardown_sanitizer_success_with_opt_in(tmp_path):
action = {"type": "action", "name": "fill", "params": {"value": "secret_password"}}
z.writestr("0-trace.trace", json.dumps(action).encode() + b"\n")
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
},
options={"resultsDir": str(results_dir)},
)
assert res.returncode == 0
# Archive is retained because sanitizer succeeded
assert trace_file.exists()
@@ -121,13 +132,15 @@ def test_teardown_missing_python_executable_fails_closed(tmp_path):
tmp_file = results_dir / "trace.tmp"
tmp_file.write_text("trace tmp")
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": "/usr/bin/nonexistent_python_binary_xyz_404",
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": "/usr/bin/nonexistent_python_binary_xyz_404",
},
options={"resultsDir": str(results_dir)},
)
assert res.returncode == 0
# Fail closed: archives must be purged
assert not trace_file.exists()
@@ -146,14 +159,18 @@ def test_teardown_nonzero_sanitizer_exit_fails_closed(tmp_path):
tmp_file = results_dir / "trace.tmp"
tmp_file.write_text("trace tmp")
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
"ODYSSEUS_TEST_SANITIZER_SCRIPT": str(failing_script),
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
},
options={
"resultsDir": str(results_dir),
"sanitizerScript": str(failing_script),
},
)
assert res.returncode == 0
assert not trace_file.exists()
assert not tmp_file.exists()
@@ -171,15 +188,19 @@ def test_teardown_sanitizer_timeout_fails_closed(tmp_path):
tmp_file = results_dir / "trace.tmp"
tmp_file.write_text("trace tmp")
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
"ODYSSEUS_TEST_SANITIZER_SCRIPT": str(hanging_script),
"ODYSSEUS_TEST_SANITIZER_TIMEOUT_MS": "300", # 300ms timeout
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
},
options={
"resultsDir": str(results_dir),
"sanitizerScript": str(hanging_script),
"timeout": 300, # 300ms timeout
},
)
assert res.returncode == 0
# Fail closed: timeout killed the process and purged archives
assert not trace_file.exists()
@@ -205,14 +226,18 @@ def test_teardown_handles_paths_containing_spaces(tmp_path):
"with open(marker, 'w') as f: f.write('ok')\n"
)
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
"ODYSSEUS_TEST_SANITIZER_SCRIPT": str(spaced_script),
"ODYSSEUS_TEST_RESULTS_DIR": str(spaced_root),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
},
options={
"resultsDir": str(spaced_root),
"sanitizerScript": str(spaced_script),
},
)
assert res.returncode == 0
assert (spaced_root / "executed.marker").exists()
@@ -242,15 +267,39 @@ def test_teardown_synthetic_command_substitution_and_metacharacters(tmp_path, pa
formatted_payload = f"python {payload.format(marker=str(marker))}"
res = _run_teardown({
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": formatted_payload,
"ODYSSEUS_TEST_RESULTS_DIR": str(results_dir),
})
res = _run_teardown(
env={
"CI": "",
"GITHUB_ACTIONS": "",
"PLAYWRIGHT_ENABLE_TRACE": "1",
"ODYSSEUS_TEST_PYTHON": formatted_payload,
},
options={"resultsDir": str(results_dir)},
)
assert res.returncode == 0
# The shell marker MUST NOT have been created!
assert not marker.exists(), f"Security failure: shell command executed for payload {payload}!"
# Fail closed: trace must be purged because python execution failed
assert not trace_file.exists()
def test_teardown_rejects_unconfined_data_directory_environment_variable(tmp_path):
"""Untrusted PHOTO_EDITOR_E2E_DATA_DIR pointing outside OS temp or lacking expected prefix is ignored."""
protected_file = tmp_path / "important_data.txt"
protected_file.write_text("do not delete")
# Target outside tmp or missing odysseus-photo-editor-e2e- prefix
res = _run_teardown(env={
"PHOTO_EDITOR_E2E_DATA_DIR": str(tmp_path),
})
assert res.returncode == 0
# Must NOT have been deleted
assert protected_file.exists(), "Security failure: unconfined data directory was deleted!"
def test_teardown_rejects_unconfined_programmatic_paths():
"""Programmatic options pointing outside allowed test roots are rejected fail-closed."""
# Attempting to target /etc or home root
res = _run_teardown(options={"resultsDir": "/etc"})
assert res.returncode == 1
assert "Security rejection" in res.stderr