7488110e71
prd-number-check / require-numbered-prds (pull_request) Successful in 8s
test / integration-docker (pull_request) Successful in 12s
test / unit (pull_request) Successful in 49s
test / coverage (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 5s
test / integration-docker (push) Successful in 13s
test / unit (push) Successful in 48s
lint / lint (push) Successful in 56s
Update Quality Badges / update-badges (push) Successful in 50s
test / coverage (push) Successful in 13s
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
"""Route matching and request-policy decisions for the egress gateway."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typing
|
|
|
|
from .types import Decision, MatchEntry, PathMatch, Route
|
|
|
|
|
|
def _path_matches(pm: PathMatch, request_path: str) -> bool:
|
|
if pm.type == "exact":
|
|
return request_path == pm.value
|
|
if pm.type == "prefix":
|
|
if request_path == pm.value:
|
|
return True
|
|
if not pm.value.endswith("/"):
|
|
return request_path.startswith(pm.value + "/")
|
|
return request_path.startswith(pm.value)
|
|
return (
|
|
pm.type == "regex"
|
|
and pm.compiled is not None
|
|
and pm.compiled.search(request_path) is not None
|
|
)
|
|
|
|
|
|
def _entry_matches(
|
|
entry: MatchEntry, request_path: str, request_method: str,
|
|
request_headers: typing.Mapping[str, str],
|
|
) -> bool:
|
|
if entry.paths and not any(_path_matches(pm, request_path) for pm in entry.paths):
|
|
return False
|
|
if entry.methods and request_method.upper() not in entry.methods:
|
|
return False
|
|
for match in entry.headers:
|
|
value = request_headers.get(match.name.lower())
|
|
if value is None:
|
|
return False
|
|
if match.type == "exact" and value != match.value:
|
|
return False
|
|
if match.type == "regex" and (
|
|
match.compiled is None or match.compiled.search(value) is None
|
|
):
|
|
return False
|
|
return True
|
|
|
|
|
|
def evaluate_matches(
|
|
route: Route, request_path: str, request_method: str = "GET",
|
|
request_headers: typing.Mapping[str, str] | None = None,
|
|
) -> bool:
|
|
"""Return whether a request satisfies a route's optional match entries."""
|
|
if not route.matches:
|
|
return True
|
|
return any(_entry_matches(entry, request_path, request_method, request_headers or {})
|
|
for entry in route.matches)
|
|
|
|
|
|
def is_git_push_request(path: str, query: str) -> bool:
|
|
return path.endswith("/git-receive-pack") or (
|
|
path.endswith("/info/refs") and any(
|
|
pair.partition("=") == ("service", "=", "git-receive-pack")
|
|
for pair in query.split("&")
|
|
)
|
|
)
|
|
|
|
|
|
def is_git_fetch_request(path: str, query: str) -> bool:
|
|
return path.endswith("/git-upload-pack") or (
|
|
path.endswith("/info/refs") and any(
|
|
pair.partition("=") == ("service", "=", "git-upload-pack")
|
|
for pair in query.split("&")
|
|
)
|
|
)
|
|
|
|
|
|
def match_route(routes: typing.Sequence[Route], request_host: str) -> Route | None:
|
|
target = request_host.lower()
|
|
return next((route for route in routes if route.host.lower() == target), None)
|
|
|
|
|
|
def decide(
|
|
routes: typing.Sequence[Route], request_host: str, request_path: str,
|
|
environ: typing.Mapping[str, str], *, request_method: str = "GET",
|
|
request_headers: typing.Mapping[str, str] | None = None, deny_reason: str = "",
|
|
) -> Decision:
|
|
route = match_route(routes, request_host)
|
|
if route is None:
|
|
return Decision("block", deny_reason or (
|
|
f"egress: host {request_host!r} is not in the bottle's egress.routes "
|
|
"allowlist. Declare a route for it or remove the request."))
|
|
if not evaluate_matches(route, request_path, request_method, request_headers):
|
|
return Decision("block", (
|
|
f"egress: request {request_method} {request_path!r} does not match any "
|
|
f"entry in matches for {route.host!r}"))
|
|
if route.auth_scheme and route.token_env:
|
|
token = environ.get(route.token_env, "")
|
|
if not token:
|
|
return Decision("block", (
|
|
f"egress: route for {route.host!r} declared auth but env var "
|
|
f"{route.token_env!r} is unset"))
|
|
return Decision("forward", inject_authorization=f"{route.auth_scheme} {token}")
|
|
return Decision("forward")
|
|
|
|
|
|
def decide_git_fetch(routes: typing.Sequence[Route], request_host: str) -> Decision:
|
|
route = match_route(routes, request_host)
|
|
if route is not None and route.git_fetch:
|
|
return Decision("forward")
|
|
return Decision("block", (
|
|
"egress: git fetch/clone over HTTPS is not allowed by default; use git-gate "
|
|
"for declared repos or set egress.routes[].git.fetch=true for explicit "
|
|
"read-only HTTPS Git access."))
|