e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
702 lines
26 KiB
Python
702 lines
26 KiB
Python
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
class _Log:
|
|
def __init__(self) -> None:
|
|
self.messages: list[str] = []
|
|
|
|
def warn(self, message: str) -> None:
|
|
self.messages.append(message)
|
|
|
|
def info(self, message: str) -> None:
|
|
self.messages.append(message)
|
|
|
|
|
|
class _Headers:
|
|
def __init__(self, values: dict[str, str]) -> None:
|
|
self._values = dict(values)
|
|
|
|
def get(self, name: str, default: str = "") -> str:
|
|
for key, value in self._values.items():
|
|
if key.lower() == name.lower():
|
|
return value
|
|
return default
|
|
|
|
def items(self) -> list[tuple[str, str]]:
|
|
return list(self._values.items())
|
|
|
|
def __setitem__(self, name: str, value: str) -> None:
|
|
self._values[name] = value
|
|
|
|
def __contains__(self, name: str) -> bool:
|
|
return any(key.lower() == name.lower() for key in self._values)
|
|
|
|
def __delitem__(self, name: str) -> None:
|
|
for key in list(self._values):
|
|
if key.lower() == name.lower():
|
|
del self._values[key]
|
|
return
|
|
|
|
|
|
class _Request:
|
|
def __init__(self) -> None:
|
|
self.pretty_host = "code.example.com"
|
|
self.host = "code.example.com"
|
|
self.port = 443
|
|
self.scheme = "https"
|
|
self.method = "GET"
|
|
self.path = "/api/v8/projects"
|
|
self.headers = _Headers({})
|
|
self.raw_content: bytes | None = None
|
|
self.content = b""
|
|
|
|
|
|
class _Response:
|
|
def __init__(self) -> None:
|
|
self.headers = _Headers(
|
|
{
|
|
"content-type": "application/json",
|
|
"x-token-echo": "secret-token",
|
|
}
|
|
)
|
|
self.stream = False
|
|
self.body = "upstream body includes secret-token"
|
|
self.set_text_called = False
|
|
|
|
def get_text(self, strict: bool = False) -> str:
|
|
return self.body
|
|
|
|
def set_text(self, value: str) -> None:
|
|
self.set_text_called = True
|
|
self.body = value
|
|
|
|
|
|
class _Flow:
|
|
def __init__(self) -> None:
|
|
self.request = _Request()
|
|
self.response = _Response()
|
|
self.metadata: dict[str, Any] = {}
|
|
|
|
|
|
def _load_system_module() -> Any:
|
|
mitmproxy = types.ModuleType("mitmproxy")
|
|
mitmproxy.ctx = types.SimpleNamespace(log=_Log(), options=types.SimpleNamespace(ignore_hosts=[]))
|
|
def _make_response(status: int, body: bytes = b"", headers: dict | None = None):
|
|
resp = _Response()
|
|
resp.status_code = status
|
|
resp.body = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
if headers:
|
|
resp.headers = _Headers(headers)
|
|
return resp
|
|
|
|
mitmproxy.http = types.SimpleNamespace(HTTPFlow=object, Response=types.SimpleNamespace(make=_make_response))
|
|
mitmproxy_tls = types.ModuleType("mitmproxy.tls")
|
|
mitmproxy_tls.ClientHelloData = object
|
|
|
|
sys.modules["mitmproxy"] = mitmproxy
|
|
sys.modules["mitmproxy.tls"] = mitmproxy_tls
|
|
|
|
path = Path(__file__).parents[1] / "mitmscripts" / "system.py"
|
|
spec = importlib.util.spec_from_file_location("opensandbox_egress_system_addon", path)
|
|
assert spec is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class SystemAddonRedactionTest(unittest.TestCase):
|
|
def test_load_active_vault_reads_unix_socket(self) -> None:
|
|
system = _load_system_module()
|
|
calls: list[tuple[str, Any, Any]] = []
|
|
|
|
class FakeResponse:
|
|
status = 200
|
|
|
|
def read(self) -> bytes:
|
|
return json.dumps(
|
|
{
|
|
"revision": 7,
|
|
"bindings": [
|
|
{
|
|
"name": "gitlab-api",
|
|
"headers": [
|
|
{"name": "Private-Token", "value": "secret-token"}
|
|
],
|
|
}
|
|
],
|
|
"redactions": ["secret-token"],
|
|
}
|
|
).encode("utf-8")
|
|
|
|
class FakeConnection:
|
|
def __init__(self, socket_path: str, timeout: float) -> None:
|
|
calls.append(("init", socket_path, timeout))
|
|
|
|
def request(self, method: str, path: str) -> None:
|
|
calls.append(("request", method, path))
|
|
|
|
def getresponse(self) -> FakeResponse:
|
|
calls.append(("getresponse", None, None))
|
|
return FakeResponse()
|
|
|
|
def close(self) -> None:
|
|
calls.append(("close", None, None))
|
|
|
|
old_socket = os.environ.get(system.CREDENTIAL_PROXY_SOCKET_ENV)
|
|
old_connection = system.UnixSocketHTTPConnection
|
|
os.environ[system.CREDENTIAL_PROXY_SOCKET_ENV] = "/tmp/active.sock"
|
|
system.UnixSocketHTTPConnection = FakeConnection
|
|
try:
|
|
vault = system._load_active_vault()
|
|
finally:
|
|
system.UnixSocketHTTPConnection = old_connection
|
|
if old_socket is None:
|
|
os.environ.pop(system.CREDENTIAL_PROXY_SOCKET_ENV, None)
|
|
else:
|
|
os.environ[system.CREDENTIAL_PROXY_SOCKET_ENV] = old_socket
|
|
|
|
self.assertIsNotNone(vault)
|
|
assert vault is not None
|
|
self.assertEqual(7, vault.revision)
|
|
self.assertEqual(["secret-token"], vault.redactions)
|
|
self.assertEqual(("init", "/tmp/active.sock", 0.25), calls[0])
|
|
self.assertEqual(("request", "GET", system.ACTIVE_VAULT_PATH), calls[1])
|
|
self.assertEqual(("close", None, None), calls[-1])
|
|
|
|
def test_request_injection_log_does_not_include_secret_value(self) -> None:
|
|
system = _load_system_module()
|
|
flow = _Flow()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "gitlab-api",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["GET"],
|
|
"paths": ["/api/v8/*"],
|
|
},
|
|
"headers": [{"name": "Private-Token", "value": "secret-token"}],
|
|
}
|
|
],
|
|
["secret-token"],
|
|
)
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual("secret-token", flow.request.headers.get("Private-Token"))
|
|
self.assertNotIn("secret-token", "\n".join(system.ctx.log.messages))
|
|
|
|
def test_responseheaders_redacts_headers_without_body_hook(self) -> None:
|
|
system = _load_system_module()
|
|
flow = _Flow()
|
|
flow.metadata[system.FLOW_REDACTIONS_KEY] = ["secret-token"]
|
|
|
|
system.responseheaders(flow)
|
|
|
|
self.assertEqual("[REDACTED]", flow.response.headers.get("x-token-echo"))
|
|
self.assertEqual("upstream body includes secret-token", flow.response.body)
|
|
self.assertFalse(flow.response.set_text_called)
|
|
self.assertFalse(hasattr(system, "response"))
|
|
|
|
def test_responseheaders_uses_injected_flow_redactions(self) -> None:
|
|
system = _load_system_module()
|
|
flow = _Flow()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "gitlab-api",
|
|
"match": {"hosts": ["code.example.com"]},
|
|
"headers": [{"name": "Private-Token", "value": "old-secret"}],
|
|
}
|
|
],
|
|
["old-secret"],
|
|
)
|
|
|
|
system.request(flow)
|
|
system._load_active_vault = lambda: system.ActiveVault(2, [], ["new-secret"])
|
|
flow.response.headers["x-token-echo"] = "old-secret"
|
|
system.responseheaders(flow)
|
|
|
|
self.assertEqual("[REDACTED]", flow.response.headers.get("x-token-echo"))
|
|
|
|
def test_responseheaders_redacts_longer_values_first(self) -> None:
|
|
system = _load_system_module()
|
|
flow = _Flow()
|
|
flow.response.headers["x-token-echo"] = "prefix token-abc suffix"
|
|
flow.metadata[system.FLOW_REDACTIONS_KEY] = ["token", "token-abc"]
|
|
|
|
system.responseheaders(flow)
|
|
|
|
self.assertEqual("prefix [REDACTED] suffix", flow.response.headers.get("x-token-echo"))
|
|
|
|
|
|
class SystemAddonSubstitutionTest(unittest.TestCase):
|
|
def _make_system_with_substitutions(self):
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "placeholder-api",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["GET", "POST"],
|
|
"paths": ["/lookup", "/tenants/*", "/token", "/form"],
|
|
},
|
|
"substitutions": [
|
|
{
|
|
"placeholder": "__query_secret__",
|
|
"value": "query secret+value",
|
|
"in": ["query"],
|
|
},
|
|
{
|
|
"placeholder": "__tenant_id__",
|
|
"value": "tenant 42",
|
|
"in": ["path"],
|
|
},
|
|
{
|
|
"placeholder": "__body_secret__",
|
|
"value": 'body "secret" \\ value',
|
|
"in": ["body"],
|
|
},
|
|
{
|
|
"placeholder": "__form_secret__",
|
|
"value": "form secret+value",
|
|
"in": ["body"],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
[
|
|
"__query_secret__",
|
|
"query secret+value",
|
|
"__tenant_id__",
|
|
"tenant 42",
|
|
"__body_secret__",
|
|
'body "secret" \\ value',
|
|
"__form_secret__",
|
|
"form secret+value",
|
|
],
|
|
)
|
|
return system
|
|
|
|
def test_query_substitution_url_encodes_value(self) -> None:
|
|
system = self._make_system_with_substitutions()
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.path = "/lookup?api_key=__query_secret__&static=1"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual("/lookup?api_key=query%20secret%2Bvalue&static=1", flow.request.path)
|
|
self.assertIn("query secret+value", flow.metadata[system.FLOW_REDACTIONS_KEY])
|
|
self.assertNotIn("query secret+value", "\n".join(system.ctx.log.messages))
|
|
|
|
def test_path_substitution_rewrites_only_path(self) -> None:
|
|
system = self._make_system_with_substitutions()
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.path = "/tenants/__tenant_id__/items?tenant=__tenant_id__"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual("/tenants/tenant%2042/items?tenant=__tenant_id__", flow.request.path)
|
|
|
|
def test_json_body_substitution_escapes_string_value_and_updates_length(self) -> None:
|
|
system = self._make_system_with_substitutions()
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.method = "POST"
|
|
flow.request.path = "/token"
|
|
flow.request.headers["content-type"] = "application/json"
|
|
flow.request.headers["transfer-encoding"] = "chunked"
|
|
flow.request.content = b'{"client_secret":"__body_secret__"}'
|
|
|
|
system.request(flow)
|
|
|
|
body = flow.request.content.decode("utf-8")
|
|
self.assertEqual({"client_secret": 'body "secret" \\ value'}, json.loads(body))
|
|
self.assertEqual(str(len(flow.request.content)), flow.request.headers.get("content-length"))
|
|
self.assertEqual("", flow.request.headers.get("transfer-encoding"))
|
|
|
|
def test_structured_json_body_substitution_escapes_string_value(self) -> None:
|
|
system = self._make_system_with_substitutions()
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.method = "POST"
|
|
flow.request.path = "/token"
|
|
flow.request.headers["content-type"] = "application/merge-patch+json; charset=utf-8"
|
|
flow.request.content = b'{"client_secret":"__body_secret__"}'
|
|
|
|
system.request(flow)
|
|
|
|
body = flow.request.content.decode("utf-8")
|
|
self.assertEqual({"client_secret": 'body "secret" \\ value'}, json.loads(body))
|
|
|
|
def test_form_body_substitution_url_encodes_value(self) -> None:
|
|
system = self._make_system_with_substitutions()
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.method = "POST"
|
|
flow.request.path = "/form"
|
|
flow.request.headers["content-type"] = "application/x-www-form-urlencoded"
|
|
flow.request.content = b"secret=__form_secret__"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual(b"secret=form+secret%2Bvalue", flow.request.content)
|
|
|
|
def test_body_substitution_does_not_rewrite_inserted_values(self) -> None:
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "nested-body",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["POST"],
|
|
"paths": ["/token"],
|
|
},
|
|
"substitutions": [
|
|
{
|
|
"placeholder": "__a__",
|
|
"value": "prefix __b__",
|
|
"in": ["body"],
|
|
},
|
|
{
|
|
"placeholder": "__b__",
|
|
"value": "secret-b",
|
|
"in": ["body"],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
["__a__", "prefix __b__", "__b__", "secret-b"],
|
|
)
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.method = "POST"
|
|
flow.request.path = "/token"
|
|
flow.request.headers["content-type"] = "text/plain"
|
|
flow.request.content = b"first=__a__&second=__b__"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual(b"first=prefix __b__&second=secret-b", flow.request.content)
|
|
|
|
def test_header_substitution_does_not_rewrite_injected_headers(self) -> None:
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "header-substitution",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["GET"],
|
|
"paths": ["/lookup"],
|
|
},
|
|
"headers": [
|
|
{"name": "Authorization", "value": "Bearer __header_secret__"}
|
|
],
|
|
"substitutions": [
|
|
{
|
|
"placeholder": "__header_secret__",
|
|
"value": "substituted-secret",
|
|
"in": ["header"],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
["__header_secret__", "substituted-secret"],
|
|
)
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.path = "/lookup"
|
|
flow.request.headers["X-Template"] = "client __header_secret__"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual("client substituted-secret", flow.request.headers.get("X-Template"))
|
|
self.assertEqual("Bearer __header_secret__", flow.request.headers.get("Authorization"))
|
|
|
|
def test_compressed_body_substitution_is_skipped(self) -> None:
|
|
system = self._make_system_with_substitutions()
|
|
flow = _Flow()
|
|
flow.response = None
|
|
flow.request.method = "POST"
|
|
flow.request.path = "/token"
|
|
flow.request.headers["content-type"] = "application/json"
|
|
flow.request.headers["content-encoding"] = "gzip"
|
|
flow.request.content = b'{"client_secret":"__body_secret__"}'
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual(b'{"client_secret":"__body_secret__"}', flow.request.content)
|
|
self.assertNotIn(system.FLOW_REDACTIONS_KEY, flow.metadata)
|
|
self.assertIn("substitution miss", "\n".join(system.ctx.log.messages))
|
|
|
|
def test_rejected_path_substitution_log_does_not_include_secret_value(self) -> None:
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "path-secret",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["GET"],
|
|
"paths": ["/tenants/*"],
|
|
},
|
|
"substitutions": [
|
|
{
|
|
"placeholder": "__tenant_id__",
|
|
"value": "tenant/secret",
|
|
"in": ["path"],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
["__tenant_id__", "tenant/secret", "tenant%2Fsecret"],
|
|
)
|
|
flow = _Flow()
|
|
flow.request.path = "/tenants/__tenant_id__/items"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
logs = "\n".join(system.ctx.log.messages)
|
|
self.assertIn("path=[REDACTED]", logs)
|
|
self.assertNotIn("tenant/secret", logs)
|
|
self.assertNotIn("tenant%2Fsecret", logs)
|
|
|
|
def test_path_substitution_rejects_nested_encoded_separator(self) -> None:
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "path-secret",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["GET"],
|
|
"paths": ["/tenants/*"],
|
|
},
|
|
"substitutions": [
|
|
{
|
|
"placeholder": "__tenant_id__",
|
|
"value": "tenant%2Fsecret",
|
|
"in": ["path"],
|
|
},
|
|
],
|
|
}
|
|
],
|
|
["__tenant_id__", "tenant%2Fsecret", "tenant%252Fsecret"],
|
|
)
|
|
flow = _Flow()
|
|
flow.request.path = "/tenants/__tenant_id__/items"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
|
|
|
|
class SystemAddonPathTraversalTest(unittest.TestCase):
|
|
"""Regression tests for CVE-like path traversal credential injection bypass."""
|
|
|
|
def _make_system_with_vault(self):
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: system.ActiveVault(
|
|
1,
|
|
[
|
|
{
|
|
"name": "gitlab-api",
|
|
"match": {
|
|
"hosts": ["code.example.com"],
|
|
"methods": ["GET"],
|
|
"paths": ["/api/v8/projects/123/*"],
|
|
},
|
|
"headers": [{"name": "Private-Token", "value": "secret-token"}],
|
|
}
|
|
],
|
|
["secret-token"],
|
|
)
|
|
return system
|
|
|
|
def test_dot_dot_traversal_rejected(self) -> None:
|
|
"""Raw .. traversal escaping path scope must be rejected with 403."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/../456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_encoded_dot_dot_traversal_rejected(self) -> None:
|
|
"""%2e%2e encoded traversal must be rejected with 403."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/%2e%2e/456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_mixed_case_encoded_dot_dot_rejected(self) -> None:
|
|
"""%2E%2e mixed-case encoded traversal must be rejected."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/%2E%2e/456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_encoded_slash_rejected(self) -> None:
|
|
"""%2f encoded path separator must be rejected."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123%2f..%2f456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_dot_dot_in_query_string_not_rejected(self) -> None:
|
|
""".. in query string is harmless and should not trigger rejection."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/variables?ref=../../main"
|
|
|
|
system.request(flow)
|
|
|
|
# Should inject credential normally (path matches the binding).
|
|
self.assertEqual("secret-token", flow.request.headers.get("Private-Token"))
|
|
|
|
def test_normal_path_within_scope_injects_credential(self) -> None:
|
|
"""Normal path within scope still receives credential injection."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertEqual("secret-token", flow.request.headers.get("Private-Token"))
|
|
|
|
def test_normal_path_outside_scope_no_injection(self) -> None:
|
|
"""Normal path outside scope does not receive credential injection."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_dot_dot_substring_not_rejected(self) -> None:
|
|
"""'..' not as a complete segment (e.g. '/.../') must NOT be blocked."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/.../data"
|
|
system.request(flow)
|
|
self.assertEqual("secret-token", flow.request.headers.get("Private-Token"))
|
|
|
|
def test_dot_dot_metadata_path_not_rejected(self) -> None:
|
|
"""``/..metadata`` is not a traversal segment."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/..metadata"
|
|
system.request(flow)
|
|
self.assertEqual("secret-token", flow.request.headers.get("Private-Token"))
|
|
|
|
def test_encoded_backslash_rejected(self) -> None:
|
|
"""%5c encoded backslash must be rejected."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/%5c..%5c456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_raw_backslash_rejected(self) -> None:
|
|
"""Raw backslash in path must be rejected."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123\\..\\456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_double_encoded_dot_dot_rejected(self) -> None:
|
|
"""Double percent-encoded traversal (%252e%252e) must be rejected."""
|
|
system = self._make_system_with_vault()
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/%252e%252e/456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
self.assertIsNotNone(flow.response)
|
|
self.assertEqual(403, flow.response.status_code)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
def test_no_vault_active_allows_dot_dot_through(self) -> None:
|
|
"""When no vault is active, ambiguous paths are not blocked (no credential risk)."""
|
|
system = _load_system_module()
|
|
system._load_active_vault = lambda: None
|
|
flow = _Flow()
|
|
flow.request.path = "/api/v8/projects/123/../456/variables"
|
|
|
|
system.request(flow)
|
|
|
|
# No response set means the request passes through unmodified.
|
|
# (flow.response is the pre-initialized _Response, not a 403)
|
|
self.assertNotIn("Private-Token", flow.request.headers._values)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|