Skip to content

Commit 3386395

Browse files
authored
Merge pull request from GHSA-2g68-c3qc-8985
restrict debugger trusted hosts
2 parents d2d3869 + 890b6b6 commit 3386395

File tree

6 files changed

+69
-11
lines changed

6 files changed

+69
-11
lines changed

Diff for: CHANGES.rst

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ Version 3.0.3
55

66
Unreleased
77

8+
- Only allow ``localhost``, ``.localhost``, ``127.0.0.1``, or the specified
9+
hostname when running the dev server, to make debugger requests. Additional
10+
hosts can be added by using the debugger middleware directly. The debugger
11+
UI makes requests using the full URL rather than only the path.
12+
:ghsa:`2g68-c3qc-8985`
813
- Make reloader more robust when ``""`` is in ``sys.path``. :pr:`2823`
914
- Better TLS cert format with ``adhoc`` dev certs. :pr:`2891`
1015
- Inform Python < 3.12 how to handle ``itms-services`` URIs correctly, rather

Diff for: docs/debug.rst

+30-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ interactive debug console to execute code in any frame.
1616
The debugger allows the execution of arbitrary code which makes it a
1717
major security risk. **The debugger must never be used on production
1818
machines. We cannot stress this enough. Do not enable the debugger
19-
in production.**
19+
in production.** Production means anything that is not development,
20+
and anything that is publicly accessible.
2021

2122
.. note::
2223

@@ -72,10 +73,9 @@ argument to get a detailed list of all the attributes it has.
7273
Debugger PIN
7374
------------
7475

75-
Starting with Werkzeug 0.11 the debug console is protected by a PIN.
76-
This is a security helper to make it less likely for the debugger to be
77-
exploited if you forget to disable it when deploying to production. The
78-
PIN based authentication is enabled by default.
76+
The debug console is protected by a PIN. This is a security helper to make it
77+
less likely for the debugger to be exploited if you forget to disable it when
78+
deploying to production. The PIN based authentication is enabled by default.
7979

8080
The first time a console is opened, a dialog will prompt for a PIN that
8181
is printed to the command line. The PIN is generated in a stable way
@@ -92,6 +92,31 @@ intended to make it harder for an attacker to exploit the debugger.
9292
Never enable the debugger in production.**
9393

9494

95+
Allowed Hosts
96+
-------------
97+
98+
The debug console will only be served if the request comes from a trusted host.
99+
If a request comes from a browser page that is not served on a trusted URL, a
100+
400 error will be returned.
101+
102+
By default, ``localhost``, any ``.localhost`` subdomain, and ``127.0.0.1`` are
103+
trusted. ``run_simple`` will trust its ``hostname`` argument as well. To change
104+
this further, use the debug middleware directly rather than through
105+
``use_debugger=True``.
106+
107+
.. code-block:: python
108+
109+
if os.environ.get("USE_DEBUGGER") in {"1", "true"}:
110+
app = DebuggedApplication(app, evalex=True)
111+
app.trusted_hosts = [...]
112+
113+
run_simple("localhost", 8080, app)
114+
115+
**This feature is not meant to entirely secure the debugger. It is
116+
intended to make it harder for an attacker to exploit the debugger.
117+
Never enable the debugger in production.**
118+
119+
95120
Pasting Errors
96121
--------------
97122

Diff for: src/werkzeug/debug/__init__.py

+28-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919

2020
from .._internal import _log
2121
from ..exceptions import NotFound
22+
from ..exceptions import SecurityError
2223
from ..http import parse_cookie
24+
from ..sansio.utils import host_is_trusted
2325
from ..security import gen_salt
2426
from ..utils import send_file
2527
from ..wrappers.request import Request
@@ -298,6 +300,14 @@ def __init__(
298300
else:
299301
self.pin = None
300302

303+
self.trusted_hosts: list[str] = [".localhost", "127.0.0.1"]
304+
"""List of domains to allow requests to the debugger from. A leading dot
305+
allows all subdomains. This only allows ``".localhost"`` domains by
306+
default.
307+
308+
.. versionadded:: 3.0.3
309+
"""
310+
301311
@property
302312
def pin(self) -> str | None:
303313
if not hasattr(self, "_pin"):
@@ -344,7 +354,7 @@ def debug_application(
344354

345355
is_trusted = bool(self.check_pin_trust(environ))
346356
html = tb.render_debugger_html(
347-
evalex=self.evalex,
357+
evalex=self.evalex and self.check_host_trust(environ),
348358
secret=self.secret,
349359
evalex_trusted=is_trusted,
350360
)
@@ -372,6 +382,9 @@ def execute_command( # type: ignore[return]
372382
frame: DebugFrameSummary | _ConsoleFrame,
373383
) -> Response:
374384
"""Execute a command in a console."""
385+
if not self.check_host_trust(request.environ):
386+
return SecurityError() # type: ignore[return-value]
387+
375388
contexts = self.frame_contexts.get(id(frame), [])
376389

377390
with ExitStack() as exit_stack:
@@ -382,6 +395,9 @@ def execute_command( # type: ignore[return]
382395

383396
def display_console(self, request: Request) -> Response:
384397
"""Display a standalone shell."""
398+
if not self.check_host_trust(request.environ):
399+
return SecurityError() # type: ignore[return-value]
400+
385401
if 0 not in self.frames:
386402
if self.console_init_func is None:
387403
ns = {}
@@ -434,12 +450,18 @@ def check_pin_trust(self, environ: WSGIEnvironment) -> bool | None:
434450
return None
435451
return (time.time() - PIN_TIME) < ts
436452

453+
def check_host_trust(self, environ: WSGIEnvironment) -> bool:
454+
return host_is_trusted(environ.get("HTTP_HOST"), self.trusted_hosts)
455+
437456
def _fail_pin_auth(self) -> None:
438457
time.sleep(5.0 if self._failed_pin_auth > 5 else 0.5)
439458
self._failed_pin_auth += 1
440459

441460
def pin_auth(self, request: Request) -> Response:
442461
"""Authenticates with the pin."""
462+
if not self.check_host_trust(request.environ):
463+
return SecurityError() # type: ignore[return-value]
464+
443465
exhausted = False
444466
auth = False
445467
trust = self.check_pin_trust(request.environ)
@@ -489,8 +511,11 @@ def pin_auth(self, request: Request) -> Response:
489511
rv.delete_cookie(self.pin_cookie_name)
490512
return rv
491513

492-
def log_pin_request(self) -> Response:
514+
def log_pin_request(self, request: Request) -> Response:
493515
"""Log the pin if needed."""
516+
if not self.check_host_trust(request.environ):
517+
return SecurityError() # type: ignore[return-value]
518+
494519
if self.pin_logging and self.pin is not None:
495520
_log(
496521
"info", " * To enable the debugger you need to enter the security pin:"
@@ -517,7 +542,7 @@ def __call__(
517542
elif cmd == "pinauth" and secret == self.secret:
518543
response = self.pin_auth(request) # type: ignore
519544
elif cmd == "printpin" and secret == self.secret:
520-
response = self.log_pin_request() # type: ignore
545+
response = self.log_pin_request(request) # type: ignore
521546
elif (
522547
self.evalex
523548
and cmd is not None

Diff for: src/werkzeug/debug/shared/debugger.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function initPinBox() {
4848
btn.disabled = true;
4949

5050
fetch(
51-
`${document.location.pathname}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}`
51+
`${document.location}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}`
5252
)
5353
.then((res) => res.json())
5454
.then(({auth, exhausted}) => {
@@ -79,7 +79,7 @@ function promptForPin() {
7979
if (!EVALEX_TRUSTED) {
8080
const encodedSecret = encodeURIComponent(SECRET);
8181
fetch(
82-
`${document.location.pathname}?__debugger__=yes&cmd=printpin&s=${encodedSecret}`
82+
`${document.location}?__debugger__=yes&cmd=printpin&s=${encodedSecret}`
8383
);
8484
const pinPrompt = document.getElementsByClassName("pin-prompt")[0];
8585
fadeIn(pinPrompt);

Diff for: src/werkzeug/sansio/utils.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ..urls import uri_to_iri
99

1010

11-
def host_is_trusted(hostname: str, trusted_list: t.Iterable[str]) -> bool:
11+
def host_is_trusted(hostname: str | None, trusted_list: t.Iterable[str]) -> bool:
1212
"""Check if a host matches a list of trusted names.
1313
1414
:param hostname: The name to check.

Diff for: src/werkzeug/serving.py

+3
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,9 @@ def run_simple(
10721072
from .debug import DebuggedApplication
10731073

10741074
application = DebuggedApplication(application, evalex=use_evalex)
1075+
# Allow the specified hostname to use the debugger, in addition to
1076+
# localhost domains.
1077+
application.trusted_hosts.append(hostname)
10751078

10761079
if not is_running_from_reloader():
10771080
fd = None

0 commit comments

Comments
 (0)