Skip to content

Commit c5893bf

Browse files
committed
fix(egress): allow single-layer %2f in raw request path
_path_is_ambiguous() rejected every request whose raw path contained a percent-encoded slash. npm scoped package registry paths are required to be encoded as /@scope%2fname on the wire (npm registry protocol), so credential injection for those requests started returning 403 as soon as the Credential Vault was active — breaking 'npm install @scope/pkg' in sandboxes. Split the two concerns: * At the raw-request stage in request(), tolerate a single-layer %2f (allow_single_encoded_slash=True). Nested encodings (%252f etc.), raw backslash, encoded backslash and dot-segments still trip the guard. * A new _path_encoded_slash_changes_binding() re-runs binding path matching against unquote(raw_path). If the encoded slash would move the request across a credential binding boundary, the request is still rejected with 403. * The post-substitution check in _apply_path_query_substitutions() keeps the strict semantics (allow_single_encoded_slash=False), so substitutions that inject a %2f into the path are still refused. Also wire mitmscripts unit tests into egress-test.yml so this regression is caught in CI, and add a Python E2E case that drives the same wire format through the real sidecar + credential vault + curl inside a sandbox against the echo target. Fixes: https://project.aone.alibaba-inc.com/v2/project/2135082/workitem/84550489
1 parent b4011b3 commit c5893bf

5 files changed

Lines changed: 406 additions & 3 deletions

File tree

.github/workflows/egress-test.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ jobs:
3131
with:
3232
go-version: '1.25.9'
3333

34+
- name: Set up Python
35+
uses: actions/setup-python@v6
36+
with:
37+
python-version: '3.10'
38+
3439
- name: Check gofmt
3540
working-directory: components/egress
3641
run: |
@@ -51,6 +56,11 @@ jobs:
5156
run: |
5257
go test ./...
5358
59+
- name: Run mitmscripts unit tests
60+
working-directory: components/egress
61+
run: |
62+
python -m unittest discover -v -s tests -p "test_*.py"
63+
5464
smoke:
5565
needs: changes
5666
if: needs.changes.outputs.relevant == 'true'

components/egress/mitmscripts/system.py

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,29 @@ def _request_path(flow: http.HTTPFlow) -> str:
191191
_DOT_SEGMENT_RE = re.compile(r"/\.\.(/|$)")
192192

193193

194-
def _path_is_ambiguous(raw_path: str) -> bool:
194+
def _path_is_ambiguous(raw_path: str, *, allow_single_encoded_slash: bool = False) -> bool:
195195
"""Return True if the raw request path contains ambiguous segments.
196196
197197
Legitimate HTTP clients resolve dot segments before sending. Raw ``..``
198198
or percent-encoded separators on the wire indicate an attempt to confuse
199199
path-based authorization checks because the canonical path seen by the
200200
upstream server may differ from the raw prefix matched here.
201+
202+
``allow_single_encoded_slash`` toggles how a single-layer ``%2f`` on the
203+
wire is treated:
204+
205+
* ``False`` (default) — historical strict behavior. Suitable for paths
206+
produced by our own substitution pipeline, where the credential proxy
207+
injects a ``%2f`` on the fly. Such a rewrite always shifts the
208+
canonical path relative to what the client sent, so it is treated as
209+
ambiguous.
210+
* ``True`` — used when checking the raw path the client sent. Legit
211+
ecosystems require a single ``%2f`` (npm scoped package registry paths
212+
like ``/@scope%2fname``). Nested encodings (``%252f`` etc.) and raw
213+
backslash / dot-segments are still rejected. The complementary
214+
:func:`_path_encoded_slash_changes_binding` check rejects a
215+
single-layer ``%2f`` if decoding it would cross an authorization
216+
boundary.
201217
"""
202218
path = raw_path.split("?", 1)[0]
203219

@@ -209,7 +225,15 @@ def _path_is_ambiguous(raw_path: str) -> bool:
209225
decoded = path
210226
for _ in range(10):
211227
lower = decoded.lower()
212-
if "%2f" in lower or "%5c" in lower:
228+
if "%2f" in lower:
229+
# A single-layer ``%2f`` on the wire is legitimate for some
230+
# ecosystems (e.g. npm scoped package registry paths use
231+
# ``/@scope%2fname``). Tolerate it on the first pass when the
232+
# caller opts in; a nested ``%252f`` still trips this check on
233+
# the next iteration because it decodes back to ``%2f``.
234+
if not (allow_single_encoded_slash and decoded is path):
235+
return True
236+
if "%5c" in lower:
213237
return True
214238
if "\\" in decoded:
215239
return True
@@ -223,6 +247,67 @@ def _path_is_ambiguous(raw_path: str) -> bool:
223247
return False
224248

225249

250+
def _path_encoded_slash_changes_binding(
251+
flow: http.HTTPFlow, vault: ActiveVault
252+
) -> bool:
253+
"""Return True if decoding ``%2f`` in the raw path would change which
254+
credential binding matches.
255+
256+
Called only when the raw request path contains ``%2f``. Legitimate uses
257+
(e.g. npm scoped package registry paths ``/@scope%2fname``) decode to a
258+
path that still matches the same binding, so this returns False and the
259+
request proceeds. A crafted path such as
260+
``/api/v8/projects/123%2f..%2f456/variables`` decodes to a different
261+
scope and this returns True so the caller can respond 403 before
262+
injecting credentials.
263+
"""
264+
raw_path = _request_path(flow)
265+
if "%2f" not in raw_path.lower():
266+
return False
267+
268+
decoded_path = unquote(raw_path)
269+
if decoded_path == raw_path:
270+
return False
271+
272+
# If the decoded form contains dot-segments, treat it as ambiguous.
273+
if _DOT_SEGMENT_RE.search(decoded_path):
274+
return True
275+
276+
scheme = (flow.request.scheme or "").lower()
277+
host = _request_host(flow)
278+
port = _request_port(flow)
279+
method = (flow.request.method or "").upper()
280+
281+
def _non_path_matches(binding: dict[str, Any]) -> bool:
282+
match = binding.get("match") or {}
283+
schemes = match.get("schemes") or ["https"]
284+
if scheme not in schemes:
285+
return False
286+
canonical_port = 443 if scheme == "https" else 80
287+
if port != canonical_port:
288+
return False
289+
methods = [m.upper() for m in (match.get("methods") or ["GET", "POST", "PUT", "PATCH", "DELETE"])]
290+
if method not in methods:
291+
return False
292+
for pattern in match.get("hosts") or []:
293+
ok, _ = _host_matches(host, pattern)
294+
if ok:
295+
return True
296+
return False
297+
298+
def _matches_with_path(path: str) -> set[int]:
299+
matched: set[int] = set()
300+
for idx, binding in enumerate(vault.bindings):
301+
if not _non_path_matches(binding):
302+
continue
303+
paths = (binding.get("match") or {}).get("paths") or ["/*"]
304+
if any(_path_matches(path, p) for p in paths):
305+
matched.add(idx)
306+
return matched
307+
308+
return _matches_with_path(raw_path) != _matches_with_path(decoded_path)
309+
310+
226311
def _host_matches(host: str, pattern: str) -> tuple[bool, int]:
227312
pattern = pattern.rstrip(".").lower()
228313
if pattern.startswith("*."):
@@ -499,8 +584,12 @@ def request(flow: http.HTTPFlow) -> None:
499584
# Raw dot-segments or encoded variants on the wire are not produced by
500585
# legitimate HTTP clients and can trick prefix-based path matching into
501586
# injecting credentials for a scope the canonical path does not belong to.
587+
# A single-layer ``%2f`` is tolerated at this stage because some legit
588+
# ecosystems require it (npm scoped packages send ``/@scope%2fname``);
589+
# the follow-up ``_path_encoded_slash_changes_binding`` check rejects any
590+
# ``%2f`` whose decoded form would cross a credential binding boundary.
502591
raw_path = flow.request.path or "/"
503-
if _path_is_ambiguous(raw_path):
592+
if _path_is_ambiguous(raw_path, allow_single_encoded_slash=True):
504593
flow.response = http.Response.make(
505594
403,
506595
b"request path contains ambiguous segments\n",
@@ -512,6 +601,25 @@ def request(flow: http.HTTPFlow) -> None:
512601
)
513602
return
514603

604+
# A single-layer ``%2f`` is tolerated by ``_path_is_ambiguous`` (legit
605+
# ecosystems like npm scoped packages require it). Reject it here only
606+
# when decoding the ``%2f`` would change which credential binding
607+
# matches, i.e. the encoded slash actually crosses an authorization
608+
# boundary. This keeps ``/@scope%2fname`` requests working while still
609+
# stopping crafted paths such as ``/api/v8/projects/123%2f..%2f456/...``.
610+
if _path_encoded_slash_changes_binding(flow, vault):
611+
flow.response = http.Response.make(
612+
403,
613+
b"request path contains ambiguous segments\n",
614+
{"content-type": "text/plain"},
615+
)
616+
ctx.log.warn(
617+
"credential proxy: rejected request whose encoded slash crosses "
618+
"the credential binding boundary: "
619+
f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}"
620+
)
621+
return
622+
515623
binding = _select_binding(flow, vault)
516624
if not binding:
517625
return

components/egress/tests/test_mitmscripts_system.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,161 @@ def test_no_vault_active_allows_dot_dot_through(self) -> None:
696696
# (flow.response is the pre-initialized _Response, not a 403)
697697
self.assertNotIn("Private-Token", flow.request.headers._values)
698698

699+
def test_encoded_slash_within_binding_scope_allowed(self) -> None:
700+
"""A ``%2f`` whose decoded form still matches the same binding must
701+
be allowed. Regression for the npm scoped package case where the
702+
registry path ``/@scope%2fname`` was rejected as a false positive."""
703+
system = self._make_system_with_vault()
704+
flow = _Flow()
705+
# ``/api/v8/*`` covers both raw and decoded forms, so this is the
706+
# tolerated case: the encoded slash does not cross a binding boundary.
707+
system._load_active_vault = lambda: system.ActiveVault(
708+
1,
709+
[
710+
{
711+
"name": "gitlab-api",
712+
"match": {
713+
"hosts": ["code.example.com"],
714+
"methods": ["GET"],
715+
"paths": ["/api/v8/*"],
716+
},
717+
"headers": [{"name": "Private-Token", "value": "secret-token"}],
718+
}
719+
],
720+
["secret-token"],
721+
)
722+
flow.request.path = "/api/v8/projects/123%2fnested/variables"
723+
724+
system.request(flow)
725+
726+
self.assertEqual("secret-token", flow.request.headers.get("Private-Token"))
727+
728+
def test_encoded_slash_crossing_binding_rejected(self) -> None:
729+
"""A ``%2f`` that decodes across a binding boundary must be rejected.
730+
731+
Two bindings are configured: one for a broad scope with a
732+
low-privilege token, and one for a narrow scope with a high-privilege
733+
token. A crafted path can match the narrow binding literally while
734+
its decoded form belongs only to the broad one — that mismatch is
735+
what the new guard must catch before injecting the wrong credential.
736+
"""
737+
system = _load_system_module()
738+
system._load_active_vault = lambda: system.ActiveVault(
739+
1,
740+
[
741+
{
742+
"name": "gitlab-api-broad",
743+
"match": {
744+
"hosts": ["code.example.com"],
745+
"methods": ["GET"],
746+
"paths": ["/api/v8/*"],
747+
},
748+
"headers": [{"name": "Private-Token", "value": "broad-token"}],
749+
},
750+
{
751+
"name": "gitlab-api-narrow",
752+
"match": {
753+
"hosts": ["code.example.com"],
754+
"methods": ["GET"],
755+
# Literal ``%2f`` in the pattern only matches the raw
756+
# form; the decoded path stops matching this pattern.
757+
"paths": ["/api/v8/projects/123%2f*"],
758+
},
759+
"headers": [{"name": "Private-Token", "value": "narrow-token"}],
760+
},
761+
],
762+
["broad-token", "narrow-token"],
763+
)
764+
flow = _Flow()
765+
flow.request.path = "/api/v8/projects/123%2fescape/variables"
766+
767+
system.request(flow)
768+
769+
# Raw match set = {broad, narrow}; decoded match set = {broad}.
770+
# The two differ, so the request is rejected before injection.
771+
self.assertIsNotNone(flow.response)
772+
self.assertEqual(403, flow.response.status_code)
773+
self.assertNotIn("Private-Token", flow.request.headers._values)
774+
775+
776+
class SystemAddonNpmScopedPackageTest(unittest.TestCase):
777+
"""npm scoped package registry paths must not be blocked as ambiguous."""
778+
779+
def _make_system_with_npm_vault(self):
780+
system = _load_system_module()
781+
system._load_active_vault = lambda: system.ActiveVault(
782+
1,
783+
[
784+
{
785+
"name": "npm-registry",
786+
"match": {
787+
"hosts": ["registry.npmjs.org"],
788+
"methods": ["GET"],
789+
"paths": ["/*"],
790+
},
791+
"headers": [{"name": "Authorization", "value": "Bearer npm-token"}],
792+
}
793+
],
794+
["npm-token"],
795+
)
796+
return system
797+
798+
def test_scoped_package_path_receives_credential(self) -> None:
799+
"""``/@scope%2fname`` is the npm-mandated wire format for scoped
800+
packages. It must reach the upstream with credentials attached."""
801+
system = self._make_system_with_npm_vault()
802+
flow = _Flow()
803+
flow.request.pretty_host = "registry.npmjs.org"
804+
flow.request.host = "registry.npmjs.org"
805+
flow.request.path = "/@ali%2forion-claude-plugin"
806+
807+
system.request(flow)
808+
809+
self.assertEqual("Bearer npm-token", flow.request.headers.get("Authorization"))
810+
self.assertIsNone(getattr(flow.response, "status_code", None))
811+
812+
def test_scoped_package_uppercase_encoding_receives_credential(self) -> None:
813+
"""Uppercase ``%2F`` variant used by some clients is equally valid."""
814+
system = self._make_system_with_npm_vault()
815+
flow = _Flow()
816+
flow.request.pretty_host = "registry.npmjs.org"
817+
flow.request.host = "registry.npmjs.org"
818+
flow.request.path = "/@ali%2Forion-claude-plugin"
819+
820+
system.request(flow)
821+
822+
self.assertEqual("Bearer npm-token", flow.request.headers.get("Authorization"))
823+
824+
def test_scoped_package_encoded_backslash_still_rejected(self) -> None:
825+
"""``%5c`` (encoded backslash) has no legitimate use even in scoped
826+
registry paths and must still be rejected."""
827+
system = self._make_system_with_npm_vault()
828+
flow = _Flow()
829+
flow.request.pretty_host = "registry.npmjs.org"
830+
flow.request.host = "registry.npmjs.org"
831+
flow.request.path = "/@ali%5corion-claude-plugin"
832+
833+
system.request(flow)
834+
835+
self.assertIsNotNone(flow.response)
836+
self.assertEqual(403, flow.response.status_code)
837+
self.assertNotIn("Authorization", flow.request.headers._values)
838+
839+
def test_scoped_package_double_encoded_slash_still_rejected(self) -> None:
840+
"""A double-encoded ``%252f`` has no legitimate use and is rejected
841+
even under the relaxed single-layer ``%2f`` policy."""
842+
system = self._make_system_with_npm_vault()
843+
flow = _Flow()
844+
flow.request.pretty_host = "registry.npmjs.org"
845+
flow.request.host = "registry.npmjs.org"
846+
flow.request.path = "/@ali%252forion-claude-plugin"
847+
848+
system.request(flow)
849+
850+
self.assertIsNotNone(flow.response)
851+
self.assertEqual(403, flow.response.status_code)
852+
self.assertNotIn("Authorization", flow.request.headers._values)
853+
699854

700855
if __name__ == "__main__":
701856
unittest.main()

tests/python/tests/support/credential_vault_echo_server.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@
4545
},
4646
}
4747

48+
# npm-style scoped package registry paths are sent on the wire with the
49+
# ``/`` between scope and package name percent-encoded (``/@scope%2fname``).
50+
# The credential proxy used to reject these as ambiguous, which broke
51+
# ``npm install`` for scoped packages inside sandboxes. This route echoes
52+
# whichever ``Authorization`` header the upstream actually received so the
53+
# same route can back two E2E cases: one where a binding matches and the
54+
# credential is injected, and one where no binding matches so the request
55+
# passes through unchanged. Both slash encodings and the canonical form are
56+
# accepted because mitmproxy is free to canonicalize the path when
57+
# forwarding.
58+
NPM_SCOPED_PATHS: frozenset[str] = frozenset(
59+
{
60+
"/npm-scoped/@ali%2forion-claude-plugin",
61+
"/npm-scoped/@ali%2Forion-claude-plugin",
62+
"/npm-scoped/@ali/orion-claude-plugin",
63+
}
64+
)
65+
4866

4967
class CredentialVaultEchoHandler(BaseHTTPRequestHandler):
5068
server_version = "OpenSandboxCredentialVaultE2E/1.0"
@@ -70,6 +88,19 @@ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
7088
)
7189
return
7290

91+
if route in NPM_SCOPED_PATHS:
92+
received = {name.lower(): value for name, value in self.headers.items()}
93+
self._write_json(
94+
HTTPStatus.OK,
95+
{
96+
"ok": True,
97+
"case": "npm-scoped",
98+
"receivedPath": route,
99+
"authorization": received.get("authorization"),
100+
},
101+
)
102+
return
103+
73104
if route == "/tenant/vault-path-secret/resource":
74105
query = parse_qs(parsed.query, keep_blank_values=True)
75106
ok = query.get("tenant") == ["__path_secret__"]

0 commit comments

Comments
 (0)