azure-dev-proxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure_dev_proxy/__init__.py +1 -0
- azure_dev_proxy/__main__.py +104 -0
- azure_dev_proxy/auth.py +382 -0
- azure_dev_proxy/config.py +367 -0
- azure_dev_proxy/default-config.json +34 -0
- azure_dev_proxy/idle_timer.py +62 -0
- azure_dev_proxy/listeners.py +108 -0
- azure_dev_proxy/proxy.py +478 -0
- azure_dev_proxy/routers/__init__.py +3 -0
- azure_dev_proxy/routers/_base.py +31 -0
- azure_dev_proxy/routers/graphiql.py +96 -0
- azure_dev_proxy/routers/management.py +247 -0
- azure_dev_proxy/routers/reverse_proxy.py +174 -0
- azure_dev_proxy/routers/utils.py +220 -0
- azure_dev_proxy/static/app.js +124 -0
- azure_dev_proxy/static/graphiql.html +109 -0
- azure_dev_proxy/static/index.html +90 -0
- azure_dev_proxy/static/login.html +26 -0
- azure_dev_proxy/tokens.py +242 -0
- azure_dev_proxy-0.1.0.dist-info/METADATA +8 -0
- azure_dev_proxy-0.1.0.dist-info/RECORD +24 -0
- azure_dev_proxy-0.1.0.dist-info/WHEEL +4 -0
- azure_dev_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- azure_dev_proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from aiohttp import web
|
|
11
|
+
|
|
12
|
+
from ..auth import HANDOFF_KEY, LOGIN_PATH, SESSION_PATH
|
|
13
|
+
from ..config import Config, TokenRule
|
|
14
|
+
from ._base import Router
|
|
15
|
+
from .utils import GRAPHQL_PREFIX
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..proxy import Proxy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
API_PATH = "/:config"
|
|
22
|
+
PERSISTED_PATH = API_PATH + "/persisted"
|
|
23
|
+
SCRIPT_PATH = "/:app.js"
|
|
24
|
+
RESPONSE_HEADERS = {
|
|
25
|
+
"Cache-Control": "no-store",
|
|
26
|
+
"X-Content-Type-Options": "nosniff",
|
|
27
|
+
"Referrer-Policy": "strict-origin",
|
|
28
|
+
"Content-Security-Policy": (
|
|
29
|
+
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; "
|
|
30
|
+
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Management(Router):
|
|
36
|
+
"""Persisted routing configuration and process-lifetime temporary overrides."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, proxy: Proxy) -> None:
|
|
39
|
+
super().__init__(proxy)
|
|
40
|
+
self._overrides: dict[str, TokenRule] = {}
|
|
41
|
+
self._csrf = secrets.token_hex(32)
|
|
42
|
+
assets = files("azure_dev_proxy").joinpath("static")
|
|
43
|
+
self._html = assets.joinpath("index.html").read_text(encoding="utf-8")
|
|
44
|
+
self._script = assets.joinpath("app.js").read_text(encoding="utf-8")
|
|
45
|
+
|
|
46
|
+
def register(self, app: web.Application) -> None:
|
|
47
|
+
"""Register local UI, config and auth paths, with a local special-path fallback."""
|
|
48
|
+
app[MANAGEMENT_KEY] = self
|
|
49
|
+
for path in (
|
|
50
|
+
"/",
|
|
51
|
+
API_PATH,
|
|
52
|
+
PERSISTED_PATH,
|
|
53
|
+
SCRIPT_PATH,
|
|
54
|
+
LOGIN_PATH,
|
|
55
|
+
SESSION_PATH,
|
|
56
|
+
):
|
|
57
|
+
app.router.add_route("*", path, self.handle)
|
|
58
|
+
app.router.add_route("*", "/:{endpoint:.*}", self.handle)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def is_local_path(path: str) -> bool:
|
|
62
|
+
"""Return whether a path belongs to the local UI or management API."""
|
|
63
|
+
return (
|
|
64
|
+
path == "/" or path.startswith("/:") and not path.startswith(GRAPHQL_PREFIX)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def is_edit_host(host: str) -> bool:
|
|
69
|
+
"""Return whether an already validated Host names the editing origin."""
|
|
70
|
+
return urlsplit("http://" + host).hostname == "localhost"
|
|
71
|
+
|
|
72
|
+
def effective_rules(
|
|
73
|
+
self, base_rules: tuple[TokenRule, ...]
|
|
74
|
+
) -> tuple[TokenRule, ...]:
|
|
75
|
+
"""Return overrides before base rules, each ordered by host specificity."""
|
|
76
|
+
return (
|
|
77
|
+
*sorted(self._overrides.values(), key=lambda rule: rule.priority),
|
|
78
|
+
*sorted(base_rules, key=lambda rule: rule.priority),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def resource_for(self, origin: str, config: Config) -> str | None:
|
|
82
|
+
"""Return the token audience selected by the effective rules."""
|
|
83
|
+
override = Config(tuple(self._overrides.values())).resource_for(origin)
|
|
84
|
+
return config.resource_for(origin) if override is None else override
|
|
85
|
+
|
|
86
|
+
def _snapshot(
|
|
87
|
+
self,
|
|
88
|
+
configuration: Config,
|
|
89
|
+
*,
|
|
90
|
+
editable: bool,
|
|
91
|
+
persistence_available: bool,
|
|
92
|
+
) -> web.Response:
|
|
93
|
+
effective = self.effective_rules(configuration.token_rules)
|
|
94
|
+
return web.json_response(
|
|
95
|
+
{
|
|
96
|
+
"base_rules": [asdict(rule) for rule in configuration.token_rules],
|
|
97
|
+
"overrides": [
|
|
98
|
+
asdict(rule) for rule in effective[: len(self._overrides)]
|
|
99
|
+
],
|
|
100
|
+
"effective_rules": [asdict(rule) for rule in effective],
|
|
101
|
+
"trusted_origins": list(configuration.trusted_origins),
|
|
102
|
+
"override_lifetime": "Until process exit, including automatic idle shutdown.",
|
|
103
|
+
"editable": editable,
|
|
104
|
+
"persistence_available": persistence_available,
|
|
105
|
+
},
|
|
106
|
+
headers=RESPONSE_HEADERS,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def validate_browser(request: web.Request) -> None:
|
|
111
|
+
"""Require same-origin management requests, except deliberate UI navigation."""
|
|
112
|
+
if request.get(HANDOFF_KEY, False):
|
|
113
|
+
return
|
|
114
|
+
host = request.headers["Host"]
|
|
115
|
+
origin = request.headers.get("Origin")
|
|
116
|
+
navigation = (
|
|
117
|
+
request.method in {"GET", "HEAD"}
|
|
118
|
+
and request.path in {"/", SESSION_PATH}
|
|
119
|
+
and request.headers.get("Sec-Fetch-Mode") == "navigate"
|
|
120
|
+
and request.headers.get("Sec-Fetch-Dest") == "document"
|
|
121
|
+
and (
|
|
122
|
+
request.path == SESSION_PATH
|
|
123
|
+
or request.headers.get("Sec-Fetch-User") == "?1"
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
if (
|
|
127
|
+
origin is not None
|
|
128
|
+
and origin != "http://" + host
|
|
129
|
+
or request.headers.get("Sec-Fetch-Site") == "cross-site"
|
|
130
|
+
and not navigation
|
|
131
|
+
):
|
|
132
|
+
raise web.HTTPForbidden(text="Local same-origin access required\n")
|
|
133
|
+
|
|
134
|
+
async def handle(self, request: web.Request) -> web.Response:
|
|
135
|
+
"""Serve local configuration without exposing tokens or forwarding CORS."""
|
|
136
|
+
proxy = self.proxy
|
|
137
|
+
self.validate_browser(request)
|
|
138
|
+
host = request.headers["Host"]
|
|
139
|
+
origin = request.headers.get("Origin")
|
|
140
|
+
editable = self.is_edit_host(host)
|
|
141
|
+
configuration = proxy.config
|
|
142
|
+
persistence_available = proxy.config_path is not None
|
|
143
|
+
if request.method in {"GET", "HEAD"} and request.path == "/":
|
|
144
|
+
port = urlsplit("http://" + host).port or 80
|
|
145
|
+
page = self._html
|
|
146
|
+
for marker, value in {
|
|
147
|
+
"__CSRF__": self._csrf if editable else "",
|
|
148
|
+
"__EDITABLE__": "true" if editable else "false",
|
|
149
|
+
"__EDIT_URL__": f"http://localhost:{port}/",
|
|
150
|
+
"__FORWARD_ORIGIN__": f"http://127.0.0.1:{port}",
|
|
151
|
+
"__EDITOR_HIDDEN__": "" if editable else "hidden",
|
|
152
|
+
"__AUTH_WARNING_HIDDEN__": (
|
|
153
|
+
"hidden" if proxy.authentication.enabled else ""
|
|
154
|
+
),
|
|
155
|
+
}.items():
|
|
156
|
+
page = page.replace(marker, value)
|
|
157
|
+
return web.Response(
|
|
158
|
+
text=page, content_type="text/html", headers=RESPONSE_HEADERS
|
|
159
|
+
)
|
|
160
|
+
if request.method in {"GET", "HEAD"} and request.path == SCRIPT_PATH:
|
|
161
|
+
return web.Response(
|
|
162
|
+
text=self._script,
|
|
163
|
+
content_type="text/javascript",
|
|
164
|
+
headers=RESPONSE_HEADERS,
|
|
165
|
+
)
|
|
166
|
+
if request.path not in {API_PATH, PERSISTED_PATH}:
|
|
167
|
+
if request.path in {"/", SCRIPT_PATH}:
|
|
168
|
+
raise web.HTTPMethodNotAllowed(request.method, ["GET", "HEAD"])
|
|
169
|
+
raise web.HTTPNotFound(text="Unknown management endpoint\n")
|
|
170
|
+
persisted = request.path == PERSISTED_PATH
|
|
171
|
+
if request.method in {"GET", "HEAD"} and not persisted:
|
|
172
|
+
return self._snapshot(
|
|
173
|
+
configuration,
|
|
174
|
+
editable=editable,
|
|
175
|
+
persistence_available=persistence_available,
|
|
176
|
+
)
|
|
177
|
+
if request.method not in {"POST", "DELETE"}:
|
|
178
|
+
allowed = (
|
|
179
|
+
["POST", "DELETE"] if persisted else ["GET", "HEAD", "POST", "DELETE"]
|
|
180
|
+
)
|
|
181
|
+
raise web.HTTPMethodNotAllowed(request.method, allowed)
|
|
182
|
+
csrf = request.headers.getall("X-Azure-Proxy-CSRF", [])
|
|
183
|
+
if (
|
|
184
|
+
not editable
|
|
185
|
+
or origin is None
|
|
186
|
+
or len(csrf) != 1
|
|
187
|
+
or not secrets.compare_digest(csrf[0], self._csrf)
|
|
188
|
+
or request.content_type != "application/json"
|
|
189
|
+
):
|
|
190
|
+
raise web.HTTPForbidden(text="Same-origin JSON and CSRF token required\n")
|
|
191
|
+
try:
|
|
192
|
+
body = await request.read()
|
|
193
|
+
value = json.loads(body)
|
|
194
|
+
if persisted and (not isinstance(value, dict) or set(value) != {"host"}):
|
|
195
|
+
raise ValueError("A persisted-rule operation requires a hostname")
|
|
196
|
+
parsed = Config.parse({"token_rules": [value]})
|
|
197
|
+
except (ValueError, UnicodeError, web.HTTPRequestEntityTooLarge):
|
|
198
|
+
raise web.HTTPBadRequest(
|
|
199
|
+
text="Invalid or oversized hostname/resource override\n"
|
|
200
|
+
) from None
|
|
201
|
+
rule = parsed.token_rules[0]
|
|
202
|
+
if persisted:
|
|
203
|
+
if proxy.config_path is None:
|
|
204
|
+
raise web.HTTPConflict(
|
|
205
|
+
text="No configuration file is available for persisted changes\n"
|
|
206
|
+
)
|
|
207
|
+
if request.method == "POST":
|
|
208
|
+
promoted = self._overrides.get(rule.host)
|
|
209
|
+
if promoted is None:
|
|
210
|
+
raise web.HTTPNotFound(
|
|
211
|
+
text="Temporary rule no longer exists; refresh and retry\n"
|
|
212
|
+
)
|
|
213
|
+
rule = promoted
|
|
214
|
+
try:
|
|
215
|
+
updated = Config.update_rule(
|
|
216
|
+
proxy.config_path,
|
|
217
|
+
rule,
|
|
218
|
+
delete=request.method == "DELETE",
|
|
219
|
+
)
|
|
220
|
+
except PermissionError:
|
|
221
|
+
raise web.HTTPForbidden(
|
|
222
|
+
text="The configuration file is read-only or not writable\n"
|
|
223
|
+
) from None
|
|
224
|
+
except ValueError:
|
|
225
|
+
raise web.HTTPConflict(
|
|
226
|
+
text="The configuration file contains invalid JSON or rules\n"
|
|
227
|
+
) from None
|
|
228
|
+
except OSError:
|
|
229
|
+
raise web.HTTPInternalServerError(
|
|
230
|
+
text="Could not update the configuration file\n"
|
|
231
|
+
) from None
|
|
232
|
+
proxy.config = Config(updated.token_rules, proxy.config.trusted_origins)
|
|
233
|
+
if request.method == "POST":
|
|
234
|
+
self._overrides.pop(rule.host, None)
|
|
235
|
+
configuration = proxy.config
|
|
236
|
+
elif request.method == "DELETE":
|
|
237
|
+
self._overrides.pop(rule.host, None)
|
|
238
|
+
else:
|
|
239
|
+
self._overrides[rule.host] = rule
|
|
240
|
+
return self._snapshot(
|
|
241
|
+
configuration,
|
|
242
|
+
editable=editable,
|
|
243
|
+
persistence_available=persistence_available,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
MANAGEMENT_KEY = web.AppKey("management", Management)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from aiohttp import ClientError, web
|
|
7
|
+
from multidict import CIMultiDict
|
|
8
|
+
from yarl import URL
|
|
9
|
+
|
|
10
|
+
from ..auth import ROUTE_KEY, TOKEN_HEADER, ProxyAuthentication
|
|
11
|
+
from ._base import Router
|
|
12
|
+
from .management import MANAGEMENT_KEY
|
|
13
|
+
from .utils import (
|
|
14
|
+
ProxyCall,
|
|
15
|
+
parse_target,
|
|
16
|
+
rewrite_refresh,
|
|
17
|
+
rewrite_url,
|
|
18
|
+
validate_destination,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
LOG = logging.getLogger("azure_dev_proxy.proxy")
|
|
22
|
+
HOP_HEADERS = {
|
|
23
|
+
"connection",
|
|
24
|
+
"keep-alive",
|
|
25
|
+
"proxy-authenticate",
|
|
26
|
+
"proxy-authorization",
|
|
27
|
+
"te",
|
|
28
|
+
"trailer",
|
|
29
|
+
"transfer-encoding",
|
|
30
|
+
"upgrade",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReverseProxy(Router):
|
|
35
|
+
"""Streaming HTTP(S) forwarding through the proxy's owned upstream session."""
|
|
36
|
+
|
|
37
|
+
def register(self, app: web.Application) -> None:
|
|
38
|
+
"""Register ordinary forwarding after all local endpoints."""
|
|
39
|
+
app[REVERSE_PROXY_KEY] = self
|
|
40
|
+
app.router.add_route("*", "/{destination:.*}", self.handle)
|
|
41
|
+
|
|
42
|
+
async def handle(self, request: web.Request) -> web.StreamResponse:
|
|
43
|
+
"""Parse and forward an ordinary destination using its raw route bytes."""
|
|
44
|
+
try:
|
|
45
|
+
target = parse_target(request[ROUTE_KEY])
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise web.HTTPBadRequest(text="Invalid destination route\n") from None
|
|
48
|
+
call = validate_destination(request, target, port=self.proxy.port)
|
|
49
|
+
return await self.forward(request, target, call)
|
|
50
|
+
|
|
51
|
+
async def forward(
|
|
52
|
+
self,
|
|
53
|
+
request: web.Request,
|
|
54
|
+
target: URL,
|
|
55
|
+
call: ProxyCall,
|
|
56
|
+
*,
|
|
57
|
+
explicit_scheme: bool = False,
|
|
58
|
+
) -> web.StreamResponse:
|
|
59
|
+
"""
|
|
60
|
+
Stream a validated HTTP(S) destination through the upstream session.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
request: Authenticated request approved by the browser policy.
|
|
64
|
+
target: Parsed destination, already checked for self-loops.
|
|
65
|
+
call: Request log context updated with the acquisition result.
|
|
66
|
+
explicit_scheme: Force explicit HTTPS in rewritten navigation,
|
|
67
|
+
as GraphiQL POSTs require. Otherwise preserve the ordinary
|
|
68
|
+
route's scheme style. HTTP always retains its scheme.
|
|
69
|
+
"""
|
|
70
|
+
proxy = self.proxy
|
|
71
|
+
origin = str(target.origin())
|
|
72
|
+
headers = filtered_headers(CIMultiDict(request.headers), request=True)
|
|
73
|
+
if "Referer" in headers:
|
|
74
|
+
referer, _ = ProxyAuthentication.strip_query_token(headers["Referer"])
|
|
75
|
+
headers["Referer"] = referer
|
|
76
|
+
headers["Host"] = target.raw_authority
|
|
77
|
+
resource = request.app[MANAGEMENT_KEY].resource_for(origin, proxy.config)
|
|
78
|
+
call.source = "non-https" if target.scheme != "https" else "not-allowlisted"
|
|
79
|
+
if resource is not None:
|
|
80
|
+
call.source = "acquisition-interrupted"
|
|
81
|
+
result = await proxy.tokens.get_with_status(origin, resource)
|
|
82
|
+
call.source = result.source
|
|
83
|
+
call.token = result.token is not None
|
|
84
|
+
if result.token is not None:
|
|
85
|
+
headers["Authorization"] = f"Bearer {result.token}"
|
|
86
|
+
call.emit(request.method)
|
|
87
|
+
if proxy.session is None:
|
|
88
|
+
raise RuntimeError("Proxy session has not started")
|
|
89
|
+
response: web.StreamResponse | None = None
|
|
90
|
+
explicit_scheme = explicit_scheme or request[ROUTE_KEY][1:].lower().startswith(
|
|
91
|
+
("http://", "https://")
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
async with proxy.session.request(
|
|
95
|
+
request.method,
|
|
96
|
+
target,
|
|
97
|
+
headers=headers,
|
|
98
|
+
data=_body_chunks(request) if request.can_read_body else None,
|
|
99
|
+
allow_redirects=False,
|
|
100
|
+
ssl=proxy.tls,
|
|
101
|
+
) as upstream:
|
|
102
|
+
response_headers = filtered_headers(
|
|
103
|
+
CIMultiDict(upstream.headers), request=False
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
for name in ("Location", "Refresh"):
|
|
107
|
+
values = response_headers.getall(name, [])
|
|
108
|
+
if values:
|
|
109
|
+
del response_headers[name]
|
|
110
|
+
for value in values:
|
|
111
|
+
rewritten = (
|
|
112
|
+
rewrite_url(
|
|
113
|
+
value,
|
|
114
|
+
target,
|
|
115
|
+
proxy.local_origin,
|
|
116
|
+
explicit_scheme=explicit_scheme,
|
|
117
|
+
)
|
|
118
|
+
if name == "Location"
|
|
119
|
+
else rewrite_refresh(
|
|
120
|
+
value,
|
|
121
|
+
target,
|
|
122
|
+
proxy.local_origin,
|
|
123
|
+
explicit_scheme=explicit_scheme,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
response_headers.add(name, rewritten)
|
|
127
|
+
except ValueError:
|
|
128
|
+
LOG.warning(
|
|
129
|
+
"Unsupported or invalid navigation header from %s", origin
|
|
130
|
+
)
|
|
131
|
+
raise web.HTTPBadGateway(
|
|
132
|
+
text="Unsupported or invalid upstream navigation target\n"
|
|
133
|
+
) from None
|
|
134
|
+
if upstream.status == 101:
|
|
135
|
+
raise web.HTTPBadGateway(
|
|
136
|
+
text="Upstream protocol upgrades are not supported\n"
|
|
137
|
+
)
|
|
138
|
+
response = web.StreamResponse(
|
|
139
|
+
status=upstream.status, headers=response_headers
|
|
140
|
+
)
|
|
141
|
+
await response.prepare(request)
|
|
142
|
+
async for chunk in upstream.content.iter_chunked(65536):
|
|
143
|
+
await response.write(chunk)
|
|
144
|
+
await response.write_eof()
|
|
145
|
+
return response
|
|
146
|
+
except (ClientError, OSError, TimeoutError) as error:
|
|
147
|
+
LOG.warning("Upstream failure for %s (%s)", origin, type(error).__name__)
|
|
148
|
+
if response is not None and response.prepared:
|
|
149
|
+
if request.transport is not None:
|
|
150
|
+
request.transport.close()
|
|
151
|
+
return response
|
|
152
|
+
if isinstance(error, TimeoutError):
|
|
153
|
+
raise web.HTTPGatewayTimeout(text="Upstream timed out\n") from None
|
|
154
|
+
raise web.HTTPBadGateway(text="Upstream connection failed\n") from None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def filtered_headers(headers: CIMultiDict[str], *, request: bool) -> CIMultiDict[str]:
|
|
158
|
+
"""Strip hop-by-hop headers and credentials while retaining duplicate fields."""
|
|
159
|
+
blocked = HOP_HEADERS | {"cookie", "set-cookie"}
|
|
160
|
+
if request:
|
|
161
|
+
blocked |= {"host", "authorization", TOKEN_HEADER.lower()}
|
|
162
|
+
for connection in headers.getall("Connection", []):
|
|
163
|
+
blocked.update(part.strip().lower() for part in connection.split(","))
|
|
164
|
+
return CIMultiDict(
|
|
165
|
+
(key, value) for key, value in headers.items() if key.lower() not in blocked
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _body_chunks(request: web.Request) -> AsyncIterator[bytes]:
|
|
170
|
+
async for chunk in request.content.iter_chunked(65536):
|
|
171
|
+
yield chunk
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
REVERSE_PROXY_KEY = web.AppKey("reverse_proxy", ReverseProxy)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from urllib.parse import unquote_plus, urljoin, urlsplit
|
|
8
|
+
|
|
9
|
+
from aiohttp import web
|
|
10
|
+
from yarl import URL
|
|
11
|
+
|
|
12
|
+
from ..config import Config
|
|
13
|
+
|
|
14
|
+
LOG = logging.getLogger("azure_dev_proxy.proxy")
|
|
15
|
+
GRAPHQL_PREFIX = "/:graphiql/"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ProxyCall:
|
|
20
|
+
"""
|
|
21
|
+
Sanitized destination and token status for one request's log entry.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
url: Destination URL with sensitive fields redacted.
|
|
25
|
+
token: Whether an Azure token was attached to the upstream request.
|
|
26
|
+
source: Token acquisition or local-response category.
|
|
27
|
+
logged: Whether the request's single log entry has already been emitted.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
url: str
|
|
31
|
+
token: bool = False
|
|
32
|
+
source: str = "not-attempted"
|
|
33
|
+
logged: bool = False
|
|
34
|
+
|
|
35
|
+
def emit(self, method: str) -> None:
|
|
36
|
+
if not self.logged:
|
|
37
|
+
LOG.info(
|
|
38
|
+
"Proxy call url=%s method=%s token=%s source=%s",
|
|
39
|
+
self.url,
|
|
40
|
+
method,
|
|
41
|
+
"yes" if self.token else "no",
|
|
42
|
+
self.source,
|
|
43
|
+
)
|
|
44
|
+
self.logged = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
CALL_KEY = web.RequestKey("proxy_call", ProxyCall)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_destination(request: web.Request, target: URL, *, port: int) -> ProxyCall:
|
|
51
|
+
"""
|
|
52
|
+
Record a parsed destination and reject loops into the local proxy.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
request: Request with its log context installed by the middleware.
|
|
56
|
+
target: Parsed HTTP(S) destination.
|
|
57
|
+
port: Local proxy listener port used to detect self-loops.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The request's log context, updated with the sanitized destination.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
web.HTTPBadRequest: The destination loops back into the proxy.
|
|
64
|
+
"""
|
|
65
|
+
call = request[CALL_KEY]
|
|
66
|
+
call.url = log_url(str(target))
|
|
67
|
+
host = target.host or ""
|
|
68
|
+
try:
|
|
69
|
+
loopback = ipaddress.ip_address(host).is_loopback
|
|
70
|
+
except ValueError:
|
|
71
|
+
loopback = host == "localhost"
|
|
72
|
+
if loopback and target.port == port:
|
|
73
|
+
raise web.HTTPBadRequest(text="Destination loops back into this proxy\n")
|
|
74
|
+
return call
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def log_url(value: str) -> str:
|
|
78
|
+
base, separator, raw_query = value.partition("?")
|
|
79
|
+
query = []
|
|
80
|
+
for parameter in raw_query.split("&"):
|
|
81
|
+
key, equals, content = parameter.partition("=")
|
|
82
|
+
normalized = re.sub(r"[^a-z0-9]", "", unquote_plus(key).lower())
|
|
83
|
+
sensitive = any(
|
|
84
|
+
word in normalized for word in ("token", "secret", "password", "signature")
|
|
85
|
+
)
|
|
86
|
+
sensitive = sensitive or normalized in {
|
|
87
|
+
"key",
|
|
88
|
+
"apikey",
|
|
89
|
+
"sig",
|
|
90
|
+
"code",
|
|
91
|
+
"authorization",
|
|
92
|
+
"credential",
|
|
93
|
+
}
|
|
94
|
+
query.append(key + equals + ("REDACTED" if sensitive and equals else content))
|
|
95
|
+
value = base + separator + "&".join(query)
|
|
96
|
+
value = re.sub(r"(?<=/)[^/?]*@", "REDACTED@", value)
|
|
97
|
+
return "".join(
|
|
98
|
+
character
|
|
99
|
+
if ord(character) >= 32 and ord(character) != 127
|
|
100
|
+
else f"\\x{ord(character):02x}"
|
|
101
|
+
for character in value
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_target(raw_path: str) -> URL:
|
|
106
|
+
"""
|
|
107
|
+
Parse a proxy destination without re-encoding its path or query.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
raw_path: Root-relative proxy path containing a bare hostname or an
|
|
111
|
+
explicit HTTP(S) URL. Bare destinations default to HTTPS.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: The route contains an invalid authority, fragment or path.
|
|
115
|
+
"""
|
|
116
|
+
route, separator, query = raw_path.partition("?")
|
|
117
|
+
if not route.startswith("/") or any(ord(c) < 32 or ord(c) == 127 for c in raw_path):
|
|
118
|
+
raise ValueError("Invalid proxy route")
|
|
119
|
+
route = route[1:]
|
|
120
|
+
scheme = "https"
|
|
121
|
+
if route[:8].lower() == "https://":
|
|
122
|
+
route = route[8:]
|
|
123
|
+
elif route[:7].lower() == "http://":
|
|
124
|
+
scheme = "http"
|
|
125
|
+
route = route[7:]
|
|
126
|
+
authority, _, path = route.partition("/")
|
|
127
|
+
authority = Config.normalize_authority(
|
|
128
|
+
authority, default_port=80 if scheme == "http" else 443
|
|
129
|
+
)
|
|
130
|
+
if "#" in raw_path or "\\" in path:
|
|
131
|
+
raise ValueError("Invalid upstream path")
|
|
132
|
+
return URL(
|
|
133
|
+
scheme
|
|
134
|
+
+ "://"
|
|
135
|
+
+ authority
|
|
136
|
+
+ "/"
|
|
137
|
+
+ path
|
|
138
|
+
+ (separator + query if separator else ""),
|
|
139
|
+
encoded=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def rewrite_url(
|
|
144
|
+
value: str,
|
|
145
|
+
upstream: URL,
|
|
146
|
+
local_origin: str,
|
|
147
|
+
*,
|
|
148
|
+
explicit_scheme: bool = False,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Resolve upstream navigation through the proxy, retaining encoded URL fields.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
value: Absolute or relative navigation URL from the upstream.
|
|
155
|
+
upstream: Destination URL against which relative references resolve.
|
|
156
|
+
local_origin: Proxy origin used for the rewritten navigation.
|
|
157
|
+
explicit_scheme: Include HTTPS explicitly rather than using a bare-host
|
|
158
|
+
proxy path. HTTP targets always include their scheme.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: The navigation target is invalid or does not use HTTP(S).
|
|
162
|
+
"""
|
|
163
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value) or "\\" in value:
|
|
164
|
+
raise ValueError("Invalid navigation URL")
|
|
165
|
+
target = urlsplit(urljoin(str(upstream), value))
|
|
166
|
+
if target.scheme not in {"http", "https"}:
|
|
167
|
+
raise ValueError("Only HTTP(S) navigation targets are supported")
|
|
168
|
+
authority = Config.normalize_authority(
|
|
169
|
+
target.netloc, default_port=80 if target.scheme == "http" else 443
|
|
170
|
+
)
|
|
171
|
+
scheme = target.scheme + "://" if explicit_scheme or target.scheme == "http" else ""
|
|
172
|
+
return (
|
|
173
|
+
local_origin
|
|
174
|
+
+ "/"
|
|
175
|
+
+ scheme
|
|
176
|
+
+ authority
|
|
177
|
+
+ (target.path or "/")
|
|
178
|
+
+ ("?" + target.query if target.query else "")
|
|
179
|
+
+ ("#" + target.fragment if target.fragment else "")
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def rewrite_refresh(
|
|
184
|
+
value: str,
|
|
185
|
+
upstream: URL,
|
|
186
|
+
local_origin: str,
|
|
187
|
+
*,
|
|
188
|
+
explicit_scheme: bool = False,
|
|
189
|
+
) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Rewrite a Refresh header's optional navigation target through the proxy.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
value: Upstream Refresh header with a delay and optional URL.
|
|
195
|
+
upstream: Destination URL against which relative references resolve.
|
|
196
|
+
local_origin: Proxy origin used for the rewritten navigation.
|
|
197
|
+
explicit_scheme: Include HTTPS explicitly, as in rewrite_url.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValueError: The header or its navigation target is invalid.
|
|
201
|
+
"""
|
|
202
|
+
match = re.fullmatch(
|
|
203
|
+
r"\s*(\d+(?:\.\d+)?)\s*(?:;\s*url\s*=\s*(.*?)\s*)?", value, re.IGNORECASE
|
|
204
|
+
)
|
|
205
|
+
if not match:
|
|
206
|
+
raise ValueError("Invalid Refresh header")
|
|
207
|
+
delay, target = match.groups()
|
|
208
|
+
if target is None:
|
|
209
|
+
return delay
|
|
210
|
+
if target.startswith(("'", '"')):
|
|
211
|
+
if len(target) < 2 or target[-1] != target[0]:
|
|
212
|
+
raise ValueError("Invalid quoted Refresh target")
|
|
213
|
+
target = target[1:-1]
|
|
214
|
+
rewritten = rewrite_url(
|
|
215
|
+
target,
|
|
216
|
+
upstream,
|
|
217
|
+
local_origin,
|
|
218
|
+
explicit_scheme=explicit_scheme,
|
|
219
|
+
)
|
|
220
|
+
return f"{delay}; url={rewritten}"
|