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.
@@ -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,3 @@
1
+ """HTTP routers and destination URL utilities for the local proxy."""
2
+
3
+ from ._base import Router as Router
@@ -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
+ )