Skip to content

Commit

Permalink
Merge branch '3.0.x'
Browse files Browse the repository at this point in the history
  • Loading branch information
davidism committed Aug 21, 2024
2 parents 65d3a84 + eed59db commit 098d0d5
Show file tree
Hide file tree
Showing 8 changed files with 99 additions and 69 deletions.
14 changes: 13 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ Unreleased
Version 3.0.4
-------------

Unreleased
Released 2024-08-21

- Restore behavior where parsing `multipart/x-www-form-urlencoded` data with
invalid UTF-8 bytes in the body results in no form data parsed rather than a
413 error. :issue:`2930`
- Improve ``parse_options_header`` performance when parsing unterminated
quoted string values. :issue:`2907`
- Debugger pin auth is synchronized across threads/processes when tracking
failed entries. :issue:`2916`
- Dev server handles unexpected `SSLEOFError` due to issue in Python < 3.13.
:issue:`2926`
- Debugger pin auth works when the URL already contains a query string.
:issue:`2918`


Version 3.0.3
Expand Down
14 changes: 9 additions & 5 deletions src/werkzeug/debug/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from contextlib import ExitStack
from io import BytesIO
from itertools import chain
from multiprocessing import Value
from os.path import basename
from os.path import join
from zlib import adler32
Expand Down Expand Up @@ -286,7 +287,7 @@ def __init__(
self.console_init_func = console_init_func
self.show_hidden_frames = show_hidden_frames
self.secret = gen_salt(20)
self._failed_pin_auth = 0
self._failed_pin_auth = Value("B")

self.pin_logging = pin_logging
if pin_security:
Expand Down Expand Up @@ -454,8 +455,11 @@ def check_host_trust(self, environ: WSGIEnvironment) -> bool:
return host_is_trusted(environ.get("HTTP_HOST"), self.trusted_hosts)

def _fail_pin_auth(self) -> None:
time.sleep(5.0 if self._failed_pin_auth > 5 else 0.5)
self._failed_pin_auth += 1
with self._failed_pin_auth.get_lock():
count = self._failed_pin_auth.value
self._failed_pin_auth.value = count + 1

time.sleep(5.0 if count > 5 else 0.5)

def pin_auth(self, request: Request) -> Response:
"""Authenticates with the pin."""
Expand All @@ -482,15 +486,15 @@ def pin_auth(self, request: Request) -> Response:
auth = True

# If we failed too many times, then we're locked out.
elif self._failed_pin_auth > 10:
elif self._failed_pin_auth.value > 10:
exhausted = True

# Otherwise go through pin based authentication
else:
entered_pin = request.args["pin"]

if entered_pin.strip().replace("-", "") == pin.replace("-", ""):
self._failed_pin_auth = 0
self._failed_pin_auth.value = 0
auth = True
else:
self._fail_pin_auth()
Expand Down
36 changes: 10 additions & 26 deletions src/werkzeug/debug/shared/debugger.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,22 @@ function wrapPlainTraceback() {
plainTraceback.replaceWith(wrapper);
}

function makeDebugURL(args) {
const params = new URLSearchParams(args)
params.set("s", SECRET)
return `?__debugger__=yes&${params}`
}

function initPinBox() {
document.querySelector(".pin-prompt form").addEventListener(
"submit",
function (event) {
event.preventDefault();
const pin = encodeURIComponent(this.pin.value);
const encodedSecret = encodeURIComponent(SECRET);
const btn = this.btn;
btn.disabled = true;

fetch(
`${document.location}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}`
makeDebugURL({cmd: "pinauth", pin: this.pin.value})
)
.then((res) => res.json())
.then(({auth, exhausted}) => {
Expand Down Expand Up @@ -77,10 +81,7 @@ function initPinBox() {

function promptForPin() {
if (!EVALEX_TRUSTED) {
const encodedSecret = encodeURIComponent(SECRET);
fetch(
`${document.location}?__debugger__=yes&cmd=printpin&s=${encodedSecret}`
);
fetch(makeDebugURL({cmd: "printpin"}));
const pinPrompt = document.getElementsByClassName("pin-prompt")[0];
fadeIn(pinPrompt);
document.querySelector('.pin-prompt input[name="pin"]').focus();
Expand Down Expand Up @@ -237,7 +238,7 @@ function createConsoleInput() {

function createIconForConsole() {
const img = document.createElement("img");
img.setAttribute("src", "?__debugger__=yes&cmd=resource&f=console.png");
img.setAttribute("src", makeDebugURL({cmd: "resource", f: "console.png"}));
img.setAttribute("title", "Open an interactive python shell in this frame");
return img;
}
Expand All @@ -263,24 +264,7 @@ function handleConsoleSubmit(e, command, frameID) {
e.preventDefault();

return new Promise((resolve) => {
// Get input command.
const cmd = command.value;

// Setup GET request.
const urlPath = "";
const params = {
__debugger__: "yes",
cmd: cmd,
frm: frameID,
s: SECRET,
};
const paramString = Object.keys(params)
.map((key) => {
return "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
})
.join("");

fetch(urlPath + "?" + paramString)
fetch(makeDebugURL({cmd: command.value, frm: frameID}))
.then((res) => {
return res.text();
})
Expand Down
14 changes: 5 additions & 9 deletions src/werkzeug/formparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,15 +281,11 @@ def _parse_urlencoded(
):
raise RequestEntityTooLarge()

try:
items = parse_qsl(
stream.read().decode(),
keep_blank_values=True,
errors="werkzeug.url_quote",
)
except ValueError as e:
raise RequestEntityTooLarge() from e

items = parse_qsl(
stream.read().decode(),
keep_blank_values=True,
errors="werkzeug.url_quote",
)
return stream, self.cls(items), self.cls()


Expand Down
65 changes: 41 additions & 24 deletions src/werkzeug/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,22 +395,8 @@ def parse_dict_header(value: str) -> dict[str, str | None]:


# https://httpwg.org/specs/rfc9110.html#parameter
_parameter_re = re.compile(
r"""
# don't match multiple empty parts, that causes backtracking
\s*;\s* # find the part delimiter
(?:
([\w!#$%&'*+\-.^`|~]+) # key, one or more token chars
= # equals, with no space on either side
( # value, token or quoted string
[\w!#$%&'*+\-.^`|~]+ # one or more token chars
|
"(?:\\\\|\\"|.)*?" # quoted string, consuming slash escapes
)
)? # optionally match key=value, to account for empty parts
""",
re.ASCII | re.VERBOSE,
)
_parameter_key_re = re.compile(r"([\w!#$%&'*+\-.^`|~]+)=", flags=re.ASCII)
_parameter_token_value_re = re.compile(r"[\w!#$%&'*+\-.^`|~]+", flags=re.ASCII)
# https://www.rfc-editor.org/rfc/rfc2231#section-4
_charset_value_re = re.compile(
r"""
Expand Down Expand Up @@ -492,18 +478,49 @@ def parse_options_header(value: str | None) -> tuple[str, dict[str, str]]:
# empty (invalid) value, or value without options
return value, {}

rest = f";{rest}"
# Collect all valid key=value parts without processing the value.
parts: list[tuple[str, str]] = []

while True:
if (m := _parameter_key_re.match(rest)) is not None:
pk = m.group(1).lower()
rest = rest[m.end() :]

# Value may be a token.
if (m := _parameter_token_value_re.match(rest)) is not None:
parts.append((pk, m.group()))

# Value may be a quoted string, find the closing quote.
elif rest[:1] == '"':
pos = 1
length = len(rest)

while pos < length:
if rest[pos : pos + 2] in {"\\\\", '\\"'}:
# Consume escaped slashes and quotes.
pos += 2
elif rest[pos] == '"':
# Stop at an unescaped quote.
parts.append((pk, rest[: pos + 1]))
rest = rest[pos + 1 :]
break
else:
# Consume any other character.
pos += 1

# Find the next section delimited by `;`, if any.
if (end := rest.find(";")) == -1:
break

rest = rest[end + 1 :].lstrip()

options: dict[str, str] = {}
encoding: str | None = None
continued_encoding: str | None = None

for pk, pv in _parameter_re.findall(rest):
if not pk:
# empty or invalid part
continue

pk = pk.lower()

# For each collected part, process optional charset and continuation,
# unquote quoted values.
for pk, pv in parts:
if pk[-1] == "*":
# key*=charset''value becomes key=value, where value is percent encoded
pk = pk[:-1]
Expand Down
9 changes: 8 additions & 1 deletion src/werkzeug/serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@

try:
import ssl

connection_dropped_errors: tuple[type[Exception], ...] = (
ConnectionError,
socket.timeout,
ssl.SSLEOFError,
)
except ImportError:

class _SslDummy:
Expand All @@ -47,6 +53,7 @@ def __getattr__(self, name: str) -> t.Any:
)

ssl = _SslDummy() # type: ignore
connection_dropped_errors = (ConnectionError, socket.timeout)

_log_add_style = True

Expand Down Expand Up @@ -361,7 +368,7 @@ def execute(app: WSGIApplication) -> None:

try:
execute(self.server.app)
except (ConnectionError, socket.timeout) as e:
except connection_dropped_errors as e:
self.connection_dropped(e, environ)
except Exception as e:
if self.server.passthrough_errors:
Expand Down
10 changes: 9 additions & 1 deletion tests/test_formparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,21 @@ def test_limiting(self):
req.max_form_parts = 1
pytest.raises(RequestEntityTooLarge, lambda: req.form["foo"])

def test_x_www_urlencoded_max_form_parts(self):
def test_urlencoded_no_max(self) -> None:
r = Request.from_values(method="POST", data={"a": 1, "b": 2})
r.max_form_parts = 1

assert r.form["a"] == "1"
assert r.form["b"] == "2"

def test_urlencoded_silent_decode(self) -> None:
r = Request.from_values(
data=b"\x80",
content_type="application/x-www-form-urlencoded",
method="POST",
)
assert not r.form

def test_missing_multipart_boundary(self):
data = (
b"--foo\r\nContent-Disposition: form-field; name=foo\r\n\r\n"
Expand Down
6 changes: 4 additions & 2 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ def test_parse_options_header_empty(self, value, expect):
('v;a="b\\"c";d=e', {"a": 'b"c', "d": "e"}),
# HTTP headers use \\ for internal \
('v;a="c:\\\\"', {"a": "c:\\"}),
# Invalid trailing slash in quoted part is left as-is.
('v;a="c:\\"', {"a": "c:\\"}),
# Part with invalid trailing slash is discarded.
('v;a="c:\\"', {}),
('v;a="b\\\\\\"c"', {"a": 'b\\"c'}),
# multipart form data uses %22 for internal "
('v;a="b%22c"', {"a": 'b"c'}),
Expand All @@ -377,6 +377,8 @@ def test_parse_options_header_empty(self, value, expect):
("v;a*0=b;a*1=c;d=e", {"a": "bc", "d": "e"}),
("v;a*0*=b", {"a": "b"}),
("v;a*0*=UTF-8''b;a*1=c;a*2*=%C2%B5", {"a": "bcµ"}),
# Long invalid quoted string with trailing slashes does not freeze.
('v;a="' + "\\" * 400, {}),
],
)
def test_parse_options_header(self, value, expect) -> None:
Expand Down

0 comments on commit 098d0d5

Please sign in to comment.