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,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}"