diff --git a/gunicorn/config.py b/gunicorn/config.py index 84e7619e..808a4b04 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -2254,5 +2254,49 @@ class StripHeaderSpaces(Setting): This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. - Use with care and only if necessary. + Use with care and only if necessary. May be removed in a future version. + + .. versionadded:: 20.0.1 """ + + +class PermitUnconventionalHTTPMethod(Setting): + name = "permit_unconventional_http_method" + section = "Server Mechanics" + cli = ["--permit-unconventional-http-method"] + validator = validate_bool + action = "store_true" + default = False + desc = """\ + Permit HTTP methods not matching conventions, such as IANA registration guidelines + + This permits request methods of length less than 3 or more than 20, + methods with lowercase characters or methods containing the # character. + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + This option is provided to diagnose backwards-incompatible changes. + + Use with care and only if necessary. May be removed in a future version. + + .. versionadded:: 22.0.0 + """ + + +class CasefoldHTTPMethod(Setting): + name = "casefold_http_method" + section = "Server Mechanics" + cli = ["--casefold-http-method"] + validator = validate_bool + action = "store_true" + default = False + desc = """\ + Transform received HTTP methods to uppercase + + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + This option is provided because previous versions of gunicorn defaulted to this behaviour. + + Use with care and only if necessary. May be removed in a future version. + + .. versionadded:: 22.0.0 + """ diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py index db3d44bb..2aa021c8 100644 --- a/gunicorn/http/message.py +++ b/gunicorn/http/message.py @@ -21,7 +21,10 @@ MAX_REQUEST_LINE = 8190 MAX_HEADERS = 32768 DEFAULT_MAX_HEADERFIELD_SIZE = 8190 -TOKEN_RE = re.compile(r"[!#$%&'*+\-.\^_`|~0-9a-zA-Z]+") +# verbosely on purpose, avoid backslash ambiguity +RFC9110_5_6_2_TOKEN_SPECIALS = r"!#$%&'*+-.^_`|~" +TOKEN_RE = re.compile(r"[%s0-9a-zA-Z]+" % (re.escape(RFC9110_5_6_2_TOKEN_SPECIALS))) +METHOD_BADCHAR_RE = re.compile("[a-z#]") VERSION_RE = re.compile(r"HTTP/(\d+)\.(\d+)") @@ -331,10 +334,23 @@ class Request(Message): if len(bits) != 3: raise InvalidRequestLine(bytes_to_str(line_bytes)) - # Method - if not TOKEN_RE.fullmatch(bits[0]): - raise InvalidRequestMethod(bits[0]) - self.method = bits[0].upper() + # Method: RFC9110 Section 9 + self.method = bits[0] + + # nonstandard restriction, suitable for all IANA registered methods + # partially enforced in previous gunicorn versions + if not self.cfg.permit_unconventional_http_method: + if METHOD_BADCHAR_RE.search(self.method): + raise InvalidRequestMethod(self.method) + if not 3 <= len(bits[0]) <= 20: + raise InvalidRequestMethod(self.method) + # standard restriction: RFC9110 token + if not TOKEN_RE.fullmatch(self.method): + raise InvalidRequestMethod(self.method) + # nonstandard and dangerous + # methods are merely uppercase by convention, no case-insensitive treatment is intended + if self.cfg.casefold_http_method: + self.method = self.method.upper() # URI self.uri = bits[1] diff --git a/tests/requests/invalid/003b.http b/tests/requests/invalid/003b.http new file mode 100644 index 00000000..e05fd989 --- /dev/null +++ b/tests/requests/invalid/003b.http @@ -0,0 +1,2 @@ +bla:rgh /foo HTTP/1.1\r\n +\r\n diff --git a/tests/requests/invalid/003b.py b/tests/requests/invalid/003b.py new file mode 100644 index 00000000..86a0774e --- /dev/null +++ b/tests/requests/invalid/003b.py @@ -0,0 +1,2 @@ +from gunicorn.http.errors import InvalidRequestMethod +request = InvalidRequestMethod \ No newline at end of file diff --git a/tests/requests/invalid/003c.http b/tests/requests/invalid/003c.http new file mode 100644 index 00000000..98bd20bf --- /dev/null +++ b/tests/requests/invalid/003c.http @@ -0,0 +1,2 @@ +-bl /foo HTTP/1.1\r\n +\r\n diff --git a/tests/requests/invalid/003c.py b/tests/requests/invalid/003c.py new file mode 100644 index 00000000..1dac27c0 --- /dev/null +++ b/tests/requests/invalid/003c.py @@ -0,0 +1,2 @@ +from gunicorn.http.errors import InvalidRequestMethod +request = InvalidRequestMethod diff --git a/tests/requests/valid/031.http b/tests/requests/valid/031.http index cd1ab7fc..ab3529da 100644 --- a/tests/requests/valid/031.http +++ b/tests/requests/valid/031.http @@ -1,2 +1,2 @@ --blargh /foo HTTP/1.1\r\n -\r\n \ No newline at end of file +-BLARGH /foo HTTP/1.1\r\n +\r\n diff --git a/tests/requests/valid/031compat.http b/tests/requests/valid/031compat.http new file mode 100644 index 00000000..cd1ab7fc --- /dev/null +++ b/tests/requests/valid/031compat.http @@ -0,0 +1,2 @@ +-blargh /foo HTTP/1.1\r\n +\r\n \ No newline at end of file diff --git a/tests/requests/valid/031compat.py b/tests/requests/valid/031compat.py new file mode 100644 index 00000000..424b7cb4 --- /dev/null +++ b/tests/requests/valid/031compat.py @@ -0,0 +1,13 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set("permit_unconventional_http_method", True) +cfg.set("casefold_http_method", True) + +request = { + "method": "-BLARGH", + "uri": uri("/foo"), + "version": (1, 1), + "headers": [], + "body": b"" +} diff --git a/tests/requests/valid/031compat2.http b/tests/requests/valid/031compat2.http new file mode 100644 index 00000000..dcbf4f13 --- /dev/null +++ b/tests/requests/valid/031compat2.http @@ -0,0 +1,2 @@ +-blargh /foo HTTP/1.1\r\n +\r\n diff --git a/tests/requests/valid/031compat2.py b/tests/requests/valid/031compat2.py new file mode 100644 index 00000000..594a8b6a --- /dev/null +++ b/tests/requests/valid/031compat2.py @@ -0,0 +1,12 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set("permit_unconventional_http_method", True) + +request = { + "method": "-blargh", + "uri": uri("/foo"), + "version": (1, 1), + "headers": [], + "body": b"" +}