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
azure_dev_proxy/proxy.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import ssl
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, web
|
|
18
|
+
|
|
19
|
+
from .auth import HANDOFF_KEY, ROUTE_KEY, ProxyAuthentication
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .idle_timer import IdleTracker
|
|
22
|
+
from .listeners import LoopbackListeners
|
|
23
|
+
from .routers import Router
|
|
24
|
+
from .routers.graphiql import GraphiQL, GraphQLRoute
|
|
25
|
+
from .routers.management import Management
|
|
26
|
+
from .routers.reverse_proxy import ReverseProxy
|
|
27
|
+
from .routers.utils import CALL_KEY, ProxyCall, log_url, parse_target
|
|
28
|
+
from .tokens import AzureCLI, TokenProvider
|
|
29
|
+
|
|
30
|
+
LOG = logging.getLogger(__name__)
|
|
31
|
+
HTTP_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SanitizedServerErrors(logging.Filter):
|
|
35
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
36
|
+
# aiohttp parser errors can otherwise include raw request credentials.
|
|
37
|
+
category = (
|
|
38
|
+
record.exc_info[0].__name__
|
|
39
|
+
if record.exc_info and record.exc_info[0]
|
|
40
|
+
else record.levelname
|
|
41
|
+
)
|
|
42
|
+
record.msg = "HTTP server failure (%s)"
|
|
43
|
+
record.args = (category,)
|
|
44
|
+
record.exc_info = None
|
|
45
|
+
record.exc_text = None
|
|
46
|
+
record.stack_info = None
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SERVER_LOG = logging.getLogger("azure_dev_proxy.http")
|
|
51
|
+
SERVER_LOG.addFilter(SanitizedServerErrors())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Proxy:
|
|
55
|
+
"""
|
|
56
|
+
A streaming HTTP(S) proxy using Azure CLI authentication only for HTTPS.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
app: Lazily created, cached aiohttp application with routing, middleware
|
|
60
|
+
and lifecycle hooks installed, ready for a runner or server.
|
|
61
|
+
port: Local loopback port shared by the UI and forwarding endpoints.
|
|
62
|
+
Defaults to zero before binding; serve sets the effective port.
|
|
63
|
+
config: Active routing settings loaded from disk or supplied by the caller.
|
|
64
|
+
Persisted edits replace this only after a successful file write.
|
|
65
|
+
config_path: Configuration file for saving rules, or null when persistence
|
|
66
|
+
is unavailable.
|
|
67
|
+
read_timeout: Upstream read-idle timeout in seconds, not a total timeout.
|
|
68
|
+
authentication: Local access-key and browser-session authentication.
|
|
69
|
+
activity: Request activity used for automatic idle shutdown.
|
|
70
|
+
routers: Router classes in registration order, with ordinary forwarding
|
|
71
|
+
last. Instances are created only when app is first accessed.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
routers: list[type[Router]] = [GraphiQL, Management, ReverseProxy]
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
tokens: TokenProvider | None = None,
|
|
79
|
+
*,
|
|
80
|
+
port: int = 0,
|
|
81
|
+
config: Config | None = None,
|
|
82
|
+
tls: ssl.SSLContext | bool = True,
|
|
83
|
+
read_timeout: float = 60,
|
|
84
|
+
authentication: ProxyAuthentication | None = None,
|
|
85
|
+
config_path: Path | None = None,
|
|
86
|
+
az_path: str = "az",
|
|
87
|
+
token: str | None = None,
|
|
88
|
+
token_file: Path | None = None,
|
|
89
|
+
no_auth: bool = False,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Configure a proxy with default services or caller-supplied dependencies.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
tokens: Token provider, or null to create one using Azure CLI.
|
|
96
|
+
port: Initial loopback port; serve sets the bound port.
|
|
97
|
+
config: Routing settings, or null to load the configuration file.
|
|
98
|
+
tls: Upstream TLS context, or whether to verify certificates.
|
|
99
|
+
read_timeout: Upstream read-idle timeout in seconds.
|
|
100
|
+
authentication: Local authentication, or null to initialize it.
|
|
101
|
+
config_path: Configuration file; defaults to the native user path
|
|
102
|
+
when config is omitted. Supplied settings without a path are
|
|
103
|
+
memory-only and use an ephemeral key by default.
|
|
104
|
+
az_path: Azure CLI executable used when tokens is omitted.
|
|
105
|
+
token: Explicit proxy key, overriding AZURE_DEV_PROXY_TOKEN.
|
|
106
|
+
token_file: Key file, overriding the environment variable. Defaults
|
|
107
|
+
to auth-token beside the configuration file.
|
|
108
|
+
no_auth: Disable authentication when authentication is omitted.
|
|
109
|
+
"""
|
|
110
|
+
if config is None:
|
|
111
|
+
config_path = (
|
|
112
|
+
config_path if config_path is not None else Config.default_path()
|
|
113
|
+
)
|
|
114
|
+
config = Config.load(config_path)
|
|
115
|
+
if config_path is not None:
|
|
116
|
+
LOG.info("Using configuration at %s", config_path)
|
|
117
|
+
if authentication is None:
|
|
118
|
+
token_path = token_file
|
|
119
|
+
if token_path is None and config_path is not None:
|
|
120
|
+
token_path = config_path.with_name("auth-token")
|
|
121
|
+
if token is None and token_file is None and not no_auth:
|
|
122
|
+
token = os.environ.get("AZURE_DEV_PROXY_TOKEN")
|
|
123
|
+
if token_path is not None:
|
|
124
|
+
authentication = ProxyAuthentication.load(
|
|
125
|
+
token_path, token=token, disabled=no_auth
|
|
126
|
+
)
|
|
127
|
+
elif no_auth:
|
|
128
|
+
authentication = ProxyAuthentication(None)
|
|
129
|
+
else:
|
|
130
|
+
authentication = ProxyAuthentication(
|
|
131
|
+
token if token is not None else secrets.token_urlsafe(32)
|
|
132
|
+
)
|
|
133
|
+
if not authentication.enabled:
|
|
134
|
+
warning = (
|
|
135
|
+
"WARNING: Proxy authentication is disabled "
|
|
136
|
+
"(other users of this machine, including applications, "
|
|
137
|
+
"can use this proxy without authorization)"
|
|
138
|
+
)
|
|
139
|
+
if sys.stderr.isatty():
|
|
140
|
+
warning = f"\x1b[31m{warning}\x1b[0m"
|
|
141
|
+
print(warning, file=sys.stderr)
|
|
142
|
+
elif token is None and token_path is not None:
|
|
143
|
+
LOG.info("Proxy authentication key file: %s", token_path)
|
|
144
|
+
self.tokens = (
|
|
145
|
+
tokens if tokens is not None else TokenProvider(AzureCLI(az_path).acquire)
|
|
146
|
+
)
|
|
147
|
+
self.port = port
|
|
148
|
+
self.config = config
|
|
149
|
+
self.config_path = config_path
|
|
150
|
+
self.tls = tls
|
|
151
|
+
self.read_timeout = read_timeout
|
|
152
|
+
self.authentication = authentication
|
|
153
|
+
self.session: ClientSession | None = None
|
|
154
|
+
self.activity = IdleTracker()
|
|
155
|
+
|
|
156
|
+
@cached_property
|
|
157
|
+
def app(self) -> web.Application:
|
|
158
|
+
"""The configured aiohttp application, created and cached on first access."""
|
|
159
|
+
app = web.Application(
|
|
160
|
+
middlewares=[self._track_request],
|
|
161
|
+
client_max_size=16384,
|
|
162
|
+
handler_args={
|
|
163
|
+
"handler_cancellation": True,
|
|
164
|
+
"logger": SERVER_LOG,
|
|
165
|
+
"auto_decompress": False,
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
app.cleanup_ctx.append(self.lifecycle)
|
|
169
|
+
app.on_response_prepare.append(self._prepare_response)
|
|
170
|
+
for router in self.routers:
|
|
171
|
+
router(self).register(app)
|
|
172
|
+
return app
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def local_origin(self) -> str:
|
|
176
|
+
"""The read-only IP-loopback origin used for forwarded navigation."""
|
|
177
|
+
return f"http://127.0.0.1:{self.port}"
|
|
178
|
+
|
|
179
|
+
def serve(self, port: int, *, idle_timeout: float = 0) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Serve a loopback port until signalled or the enabled idle timeout.
|
|
182
|
+
|
|
183
|
+
Owns the listeners and event loop and blocks until shutdown. Inherited
|
|
184
|
+
systemd sockets are adopted before the event loop is created. Call
|
|
185
|
+
outside a running event loop. Sets port to the effective listener port.
|
|
186
|
+
|
|
187
|
+
Socket activation is optional. A separately configured systemd socket
|
|
188
|
+
unit can start a new process when a connection arrives after idle exit.
|
|
189
|
+
Direct startup, including normal Windows startup, has no
|
|
190
|
+
connection-triggered restart. This proxy is not a Windows Service and
|
|
191
|
+
does not integrate with the Windows Service Control Manager.
|
|
192
|
+
|
|
193
|
+
Active requests and streams prevent idle exit. Graceful stopping uses
|
|
194
|
+
the platform's supported signal handling, including console Ctrl+C on
|
|
195
|
+
Windows. The proxy does not arrange its own restart after stopping.
|
|
196
|
+
Stopping cleans up the application and its owned token acquisitions.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
port: Loopback TCP port shared by IPv4 and available IPv6 listeners.
|
|
200
|
+
Zero selects a free port for direct startup; inherited sockets
|
|
201
|
+
must match the specified port.
|
|
202
|
+
idle_timeout: Inactive seconds before process exit when positive.
|
|
203
|
+
Defaults to zero, disabling automatic idle shutdown.
|
|
204
|
+
"""
|
|
205
|
+
with LoopbackListeners(port) as listeners:
|
|
206
|
+
self.port = listeners[0].getsockname()[1]
|
|
207
|
+
asyncio.run(self._serve(listeners, idle_timeout=idle_timeout))
|
|
208
|
+
|
|
209
|
+
async def _serve(
|
|
210
|
+
self, listeners: list[socket.socket], *, idle_timeout: float = 0
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Run the application on owned sockets and clean up on shutdown."""
|
|
213
|
+
runner = web.AppRunner(self.app, access_log=None, shutdown_timeout=35)
|
|
214
|
+
stop = asyncio.Event()
|
|
215
|
+
loop = asyncio.get_running_loop()
|
|
216
|
+
tasks: list[asyncio.Task[None] | asyncio.Task[bool]] = []
|
|
217
|
+
signals = (signal.SIGINT, signal.SIGTERM)
|
|
218
|
+
registered_signals = []
|
|
219
|
+
fallback_signals = {}
|
|
220
|
+
try:
|
|
221
|
+
await runner.setup()
|
|
222
|
+
for listener in listeners:
|
|
223
|
+
await web.SockSite(runner, listener).start()
|
|
224
|
+
for signum in signals:
|
|
225
|
+
try:
|
|
226
|
+
loop.add_signal_handler(signum, stop.set)
|
|
227
|
+
registered_signals.append(signum)
|
|
228
|
+
except NotImplementedError:
|
|
229
|
+
fallback_signals[signum] = signal.signal(
|
|
230
|
+
signum,
|
|
231
|
+
lambda *_: loop.call_soon_threadsafe(stop.set),
|
|
232
|
+
)
|
|
233
|
+
print(
|
|
234
|
+
f"Azure proxy listening at http://localhost:{self.port}",
|
|
235
|
+
flush=True,
|
|
236
|
+
)
|
|
237
|
+
if self.authentication.enabled:
|
|
238
|
+
for host in ("localhost", "127.0.0.1"):
|
|
239
|
+
LOG.info(
|
|
240
|
+
"%s",
|
|
241
|
+
self.authentication.login_url(f"http://{host}:{self.port}"),
|
|
242
|
+
)
|
|
243
|
+
tasks = [asyncio.create_task(stop.wait())]
|
|
244
|
+
idle = None
|
|
245
|
+
if idle_timeout > 0:
|
|
246
|
+
idle = asyncio.create_task(self.activity.wait_for_idle(idle_timeout))
|
|
247
|
+
tasks.append(idle)
|
|
248
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
249
|
+
if idle is not None and idle.done():
|
|
250
|
+
idle.result()
|
|
251
|
+
LOG.info("Idle timeout reached; shutting down")
|
|
252
|
+
finally:
|
|
253
|
+
for task in tasks:
|
|
254
|
+
task.cancel()
|
|
255
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
256
|
+
for signum in registered_signals:
|
|
257
|
+
loop.remove_signal_handler(signum)
|
|
258
|
+
for signum, handler in fallback_signals.items():
|
|
259
|
+
signal.signal(signum, handler)
|
|
260
|
+
await runner.cleanup()
|
|
261
|
+
|
|
262
|
+
async def lifecycle(self, app: web.Application) -> AsyncIterator[None]:
|
|
263
|
+
async with ClientSession(
|
|
264
|
+
cookie_jar=DummyCookieJar(),
|
|
265
|
+
auto_decompress=False,
|
|
266
|
+
timeout=ClientTimeout(
|
|
267
|
+
total=None, sock_connect=10, sock_read=self.read_timeout
|
|
268
|
+
),
|
|
269
|
+
skip_auto_headers={"Accept-Encoding", "User-Agent", "Content-Type"},
|
|
270
|
+
) as session:
|
|
271
|
+
self.session = session
|
|
272
|
+
try:
|
|
273
|
+
yield
|
|
274
|
+
finally:
|
|
275
|
+
await self.tokens.close()
|
|
276
|
+
self.session = None
|
|
277
|
+
|
|
278
|
+
def validate_client(self, request: web.Request) -> None:
|
|
279
|
+
hosts = request.headers.getall("Host", [])
|
|
280
|
+
if len(hosts) != 1:
|
|
281
|
+
raise web.HTTPBadRequest(text="A single local Host header is required\n")
|
|
282
|
+
try:
|
|
283
|
+
host = urlsplit("http://" + hosts[0])
|
|
284
|
+
valid = Config.normalize_host(host.hostname or "") in {
|
|
285
|
+
"localhost",
|
|
286
|
+
"127.0.0.1",
|
|
287
|
+
"::1",
|
|
288
|
+
}
|
|
289
|
+
valid = valid and (host.port or 80) == self.port
|
|
290
|
+
valid = valid and not host.username and not host.password and not host.path
|
|
291
|
+
valid = valid and not host.query and not host.fragment
|
|
292
|
+
except ValueError:
|
|
293
|
+
valid = False
|
|
294
|
+
if not valid:
|
|
295
|
+
raise web.HTTPBadRequest(text="Invalid local Host header\n")
|
|
296
|
+
if len(request.headers.getall("Origin", [])) > 1:
|
|
297
|
+
raise web.HTTPBadRequest(text="A single Origin header is required\n")
|
|
298
|
+
if request.method == "CONNECT":
|
|
299
|
+
raise web.HTTPMethodNotAllowed(request.method, HTTP_METHODS)
|
|
300
|
+
if request.headers.get("Upgrade"):
|
|
301
|
+
raise web.HTTPBadRequest(text="Protocol upgrades are not supported\n")
|
|
302
|
+
|
|
303
|
+
def is_trusted_origin(self, origin: str) -> bool:
|
|
304
|
+
"""Return whether a browser origin may use the forwarding proxy."""
|
|
305
|
+
try:
|
|
306
|
+
origin = Config.normalize_origin(origin)
|
|
307
|
+
except ValueError:
|
|
308
|
+
return False
|
|
309
|
+
local = {
|
|
310
|
+
Config.normalize_origin(f"http://{host}:{self.port}")
|
|
311
|
+
for host in ("localhost", "127.0.0.1", "[::1]")
|
|
312
|
+
}
|
|
313
|
+
return origin in local or origin in self.config.trusted_origins
|
|
314
|
+
|
|
315
|
+
def validate_browser(self, request: web.Request) -> None:
|
|
316
|
+
"""Reject untrusted browser requests before forwarding or acquiring tokens."""
|
|
317
|
+
if request.get(HANDOFF_KEY, False):
|
|
318
|
+
return
|
|
319
|
+
origin = request.headers.get("Origin")
|
|
320
|
+
if origin is not None:
|
|
321
|
+
allowed = self.is_trusted_origin(origin)
|
|
322
|
+
elif request.headers.get("Referer") is not None:
|
|
323
|
+
try:
|
|
324
|
+
referer = urlsplit(request.headers["Referer"])
|
|
325
|
+
allowed = self.is_trusted_origin(f"{referer.scheme}://{referer.netloc}")
|
|
326
|
+
except ValueError:
|
|
327
|
+
allowed = False
|
|
328
|
+
else:
|
|
329
|
+
site = request.headers.get("Sec-Fetch-Site")
|
|
330
|
+
direct_navigation = (
|
|
331
|
+
site == "none"
|
|
332
|
+
and request.method in {"GET", "HEAD"}
|
|
333
|
+
and request.headers.get("Sec-Fetch-Mode") == "navigate"
|
|
334
|
+
and request.headers.get("Sec-Fetch-Dest") == "document"
|
|
335
|
+
and request.headers.get("Sec-Fetch-User") == "?1"
|
|
336
|
+
)
|
|
337
|
+
browser = any(
|
|
338
|
+
name.lower().startswith("sec-fetch-") for name in request.headers
|
|
339
|
+
)
|
|
340
|
+
allowed = not browser or site == "same-origin" or direct_navigation
|
|
341
|
+
if not allowed:
|
|
342
|
+
raise web.HTTPForbidden(
|
|
343
|
+
text="Browser origin is not trusted for forwarding\n"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
async def _prepare_response(
|
|
347
|
+
self, request: web.Request, response: web.StreamResponse
|
|
348
|
+
) -> None:
|
|
349
|
+
self.authentication.prepare_response(request, response)
|
|
350
|
+
if Management.is_local_path(request.path):
|
|
351
|
+
return
|
|
352
|
+
origin = request.headers.get("Origin")
|
|
353
|
+
if origin and self.is_trusted_origin(origin):
|
|
354
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
355
|
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
356
|
+
exposed = {}
|
|
357
|
+
for value in (
|
|
358
|
+
*response.headers.getall("Access-Control-Expose-Headers", []),
|
|
359
|
+
"Location, Refresh",
|
|
360
|
+
):
|
|
361
|
+
for name in value.split(","):
|
|
362
|
+
name = name.strip()
|
|
363
|
+
if name:
|
|
364
|
+
exposed.setdefault(name.lower(), name)
|
|
365
|
+
response.headers["Access-Control-Expose-Headers"] = ", ".join(
|
|
366
|
+
exposed.values()
|
|
367
|
+
)
|
|
368
|
+
vary = {
|
|
369
|
+
value.strip().lower()
|
|
370
|
+
for header in response.headers.getall("Vary", [])
|
|
371
|
+
for value in header.split(",")
|
|
372
|
+
}
|
|
373
|
+
if "*" not in vary:
|
|
374
|
+
for name in (
|
|
375
|
+
"Origin",
|
|
376
|
+
"Referer",
|
|
377
|
+
"Sec-Fetch-Site",
|
|
378
|
+
"Sec-Fetch-Mode",
|
|
379
|
+
"Sec-Fetch-Dest",
|
|
380
|
+
"Sec-Fetch-User",
|
|
381
|
+
):
|
|
382
|
+
if name.lower() not in vary:
|
|
383
|
+
response.headers.add("Vary", name)
|
|
384
|
+
|
|
385
|
+
async def _route_request(
|
|
386
|
+
self,
|
|
387
|
+
request: web.Request,
|
|
388
|
+
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
|
389
|
+
) -> web.StreamResponse:
|
|
390
|
+
route, query_tokens = ProxyAuthentication.strip_query_token(request.raw_path)
|
|
391
|
+
request[ROUTE_KEY] = route
|
|
392
|
+
call = ProxyCall(log_url(self.local_origin + route))
|
|
393
|
+
request[CALL_KEY] = call
|
|
394
|
+
try:
|
|
395
|
+
self.validate_client(request)
|
|
396
|
+
request[HANDOFF_KEY] = self.authentication.is_handoff(request, query_tokens)
|
|
397
|
+
if Management.is_local_path(request.path):
|
|
398
|
+
Management.validate_browser(request)
|
|
399
|
+
response = await self.authentication.authorize(request, query_tokens)
|
|
400
|
+
if response is not None:
|
|
401
|
+
return response
|
|
402
|
+
return await handler(request)
|
|
403
|
+
self.validate_browser(request)
|
|
404
|
+
if (
|
|
405
|
+
request.method == "OPTIONS"
|
|
406
|
+
and request.headers.get("Origin")
|
|
407
|
+
and request.headers.get("Access-Control-Request-Method")
|
|
408
|
+
):
|
|
409
|
+
try:
|
|
410
|
+
graphql = GraphQLRoute.parse(route)
|
|
411
|
+
target = graphql.target if graphql else parse_target(route)
|
|
412
|
+
except ValueError:
|
|
413
|
+
raise web.HTTPBadRequest(
|
|
414
|
+
text="Invalid destination route\n"
|
|
415
|
+
) from None
|
|
416
|
+
call.url = log_url(str(target))
|
|
417
|
+
call.source = "cors-preflight"
|
|
418
|
+
method = request.headers["Access-Control-Request-Method"]
|
|
419
|
+
if method not in HTTP_METHODS:
|
|
420
|
+
raise web.HTTPMethodNotAllowed(method, HTTP_METHODS)
|
|
421
|
+
requested_headers = [
|
|
422
|
+
name.strip()
|
|
423
|
+
for name in request.headers.get(
|
|
424
|
+
"Access-Control-Request-Headers", ""
|
|
425
|
+
).split(",")
|
|
426
|
+
if name.strip()
|
|
427
|
+
]
|
|
428
|
+
if any(
|
|
429
|
+
not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", name)
|
|
430
|
+
for name in requested_headers
|
|
431
|
+
):
|
|
432
|
+
raise web.HTTPBadRequest(
|
|
433
|
+
text="Invalid CORS requested header name\n"
|
|
434
|
+
)
|
|
435
|
+
headers = {"Access-Control-Allow-Methods": ", ".join(HTTP_METHODS)}
|
|
436
|
+
if requested_headers:
|
|
437
|
+
headers["Access-Control-Allow-Headers"] = ", ".join(
|
|
438
|
+
requested_headers
|
|
439
|
+
)
|
|
440
|
+
if (
|
|
441
|
+
request.headers.get("Access-Control-Request-Private-Network")
|
|
442
|
+
== "true"
|
|
443
|
+
):
|
|
444
|
+
headers["Access-Control-Allow-Private-Network"] = "true"
|
|
445
|
+
return web.Response(status=204, headers=headers)
|
|
446
|
+
response = await self.authentication.authorize(request, query_tokens)
|
|
447
|
+
if response is not None:
|
|
448
|
+
return response
|
|
449
|
+
if Management.is_edit_host(request.headers["Host"]):
|
|
450
|
+
try:
|
|
451
|
+
if GraphQLRoute.parse(route) is None:
|
|
452
|
+
parse_target(route)
|
|
453
|
+
except ValueError:
|
|
454
|
+
raise web.HTTPBadRequest(
|
|
455
|
+
text="Invalid destination route\n"
|
|
456
|
+
) from None
|
|
457
|
+
if self.authentication.enabled and request.method in {"GET", "HEAD"}:
|
|
458
|
+
return self.authentication.handoff_response(request, route)
|
|
459
|
+
return web.Response(
|
|
460
|
+
status=307,
|
|
461
|
+
text="Forwarding uses the read-only loopback origin\n",
|
|
462
|
+
headers={
|
|
463
|
+
"Location": self.local_origin + route,
|
|
464
|
+
"Cache-Control": "no-store",
|
|
465
|
+
},
|
|
466
|
+
)
|
|
467
|
+
return await handler(request)
|
|
468
|
+
finally:
|
|
469
|
+
call.emit(request.method)
|
|
470
|
+
|
|
471
|
+
@web.middleware
|
|
472
|
+
async def _track_request(
|
|
473
|
+
self,
|
|
474
|
+
request: web.Request,
|
|
475
|
+
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
|
476
|
+
) -> web.StreamResponse:
|
|
477
|
+
with self.activity.request():
|
|
478
|
+
return await self._route_request(request, handler)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared interface for the local proxy's HTTP routers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..proxy import Proxy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Router(ABC):
|
|
15
|
+
"""
|
|
16
|
+
HTTP endpoints bound to one proxy instance.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
proxy: Service supplying authentication, configuration and upstream I/O.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, proxy: Proxy) -> None:
|
|
23
|
+
self.proxy = proxy
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def register(self, app: web.Application) -> None:
|
|
27
|
+
"""Register this router's endpoints on an application."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def handle(self, request: web.Request) -> web.StreamResponse:
|
|
31
|
+
"""Handle a request after the proxy's cross-cutting policy checks."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
from yarl import URL
|
|
10
|
+
|
|
11
|
+
from ..auth import ROUTE_KEY
|
|
12
|
+
from ._base import Router
|
|
13
|
+
from .reverse_proxy import REVERSE_PROXY_KEY
|
|
14
|
+
from .utils import GRAPHQL_PREFIX, parse_target, validate_destination
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphiQL(Router):
|
|
18
|
+
"""GraphiQL pages and POST forwarding for an HTTP(S) API destination."""
|
|
19
|
+
|
|
20
|
+
def register(self, app: web.Application) -> None:
|
|
21
|
+
"""Register the GraphiQL destination path before special-path fallbacks."""
|
|
22
|
+
app.router.add_route("*", GRAPHQL_PREFIX + "{destination:.*}", self.handle)
|
|
23
|
+
|
|
24
|
+
async def handle(self, request: web.Request) -> web.StreamResponse:
|
|
25
|
+
"""Serve the GraphiQL page for GET/HEAD, or forward a POST to its API."""
|
|
26
|
+
try:
|
|
27
|
+
route = GraphQLRoute.parse(request[ROUTE_KEY])
|
|
28
|
+
if route is None:
|
|
29
|
+
raise ValueError("Invalid GraphiQL prefix")
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise web.HTTPBadRequest(text="Invalid destination route\n") from None
|
|
32
|
+
call = validate_destination(request, route.target, port=self.proxy.port)
|
|
33
|
+
if request.method in {"GET", "HEAD"}:
|
|
34
|
+
call.source = "graphiql-ui"
|
|
35
|
+
return route.page()
|
|
36
|
+
if request.method != "POST":
|
|
37
|
+
raise web.HTTPMethodNotAllowed(request.method, ["GET", "HEAD", "POST"])
|
|
38
|
+
return await request.app[REVERSE_PROXY_KEY].forward(
|
|
39
|
+
request, route.target, call, explicit_scheme=True
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class GraphQLRoute:
|
|
45
|
+
"""
|
|
46
|
+
A local GraphiQL page for an HTTP(S) API.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
target: HTTP(S) API URL preserving the encoded path and query.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
target: URL
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def parse(cls, raw_path: str) -> GraphQLRoute | None:
|
|
56
|
+
"""Parse GraphiQL routes with HTTPS by default, or return None for other paths."""
|
|
57
|
+
if not raw_path.startswith(GRAPHQL_PREFIX):
|
|
58
|
+
return None
|
|
59
|
+
value = raw_path[len(GRAPHQL_PREFIX) :]
|
|
60
|
+
if (
|
|
61
|
+
any(ord(char) <= 32 or ord(char) == 127 for char in raw_path)
|
|
62
|
+
or "#" in raw_path
|
|
63
|
+
or "\\" in raw_path
|
|
64
|
+
):
|
|
65
|
+
raise ValueError("Invalid GraphQL route")
|
|
66
|
+
return cls(parse_target("/" + value))
|
|
67
|
+
|
|
68
|
+
def page(self) -> web.Response:
|
|
69
|
+
"""Render the bundled GraphiQL UI without contacting the upstream API."""
|
|
70
|
+
nonce = secrets.token_urlsafe(24)
|
|
71
|
+
endpoint = json.dumps(str(self.target), ensure_ascii=True).replace(
|
|
72
|
+
"<", "\\u003c"
|
|
73
|
+
)
|
|
74
|
+
template = (
|
|
75
|
+
files("azure_dev_proxy")
|
|
76
|
+
.joinpath("static/graphiql.html")
|
|
77
|
+
.read_text(encoding="utf-8")
|
|
78
|
+
)
|
|
79
|
+
page = template.replace("__NONCE__", nonce).replace(
|
|
80
|
+
"__TRANSPORT_URL__", endpoint
|
|
81
|
+
)
|
|
82
|
+
return web.Response(
|
|
83
|
+
text=page,
|
|
84
|
+
content_type="text/html",
|
|
85
|
+
headers={
|
|
86
|
+
"Cache-Control": "no-store",
|
|
87
|
+
"X-Content-Type-Options": "nosniff",
|
|
88
|
+
"Referrer-Policy": "no-referrer",
|
|
89
|
+
"Content-Security-Policy": (
|
|
90
|
+
f"default-src 'none'; script-src 'nonce-{nonce}' https://esm.sh data:; "
|
|
91
|
+
"style-src 'unsafe-inline' https://esm.sh; connect-src 'self' https://esm.sh; "
|
|
92
|
+
"worker-src blob: https://esm.sh; img-src data:; font-src https://esm.sh data:; "
|
|
93
|
+
"frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
)
|