diff --git a/docs/source/settings.rst b/docs/source/settings.rst index c20af3da7..13db13a5f 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -1396,6 +1396,26 @@ The variables are passed to the PasteDeploy entrypoint. Example:: .. versionadded:: 19.7 +.. _permit-obsolete-folding: + +``permit_obsolete_folding`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Command line:** ``--permit-obsolete-folding`` + +**Default:** ``False`` + +Permit requests employing obsolete HTTP line folding mechanism + +The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be + employed in HTTP request headers from standards-compliant HTTP clients. + +This option is provided to diagnose backwards-incompatible changes. +Use with care and only if necessary. Temporary; the precise effect of this option may +change in a future version, or it may be removed altogether. + +.. versionadded:: 23.0.0 + .. _strip-header-spaces: ``strip_header_spaces`` diff --git a/gunicorn/config.py b/gunicorn/config.py index a0366264f..1c460193b 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -2243,6 +2243,27 @@ class PasteGlobalConf(Setting): """ +class PermitObsoleteFolding(Setting): + name = "permit_obsolete_folding" + section = "Server Mechanics" + cli = ["--permit-obsolete-folding"] + validator = validate_bool + action = "store_true" + default = False + desc = """\ + Permit requests employing obsolete HTTP line folding mechanism + + The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be + employed in HTTP request headers from standards-compliant HTTP clients. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + + .. versionadded:: 23.0.0 + """ + + class StripHeaderSpaces(Setting): name = "strip_header_spaces" section = "Server Mechanics" diff --git a/gunicorn/http/errors.py b/gunicorn/http/errors.py index 1e3c5e752..861dcb4f8 100644 --- a/gunicorn/http/errors.py +++ b/gunicorn/http/errors.py @@ -65,6 +65,14 @@ def __str__(self): return "Invalid HTTP Header: %r" % self.hdr +class ObsoleteFolding(ParseException): + def __init__(self, hdr): + self.hdr = hdr + + def __str__(self): + return "Obsolete line folding is unacceptable: %r" % (self.hdr, ) + + class InvalidHeaderName(ParseException): def __init__(self, hdr): self.hdr = hdr diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py index 1cb48ef30..0bf81d117 100644 --- a/gunicorn/http/message.py +++ b/gunicorn/http/message.py @@ -12,7 +12,7 @@ InvalidHeader, InvalidHeaderName, NoMoreData, InvalidRequestLine, InvalidRequestMethod, InvalidHTTPVersion, LimitRequestLine, LimitRequestHeaders, - UnsupportedTransferCoding, + UnsupportedTransferCoding, ObsoleteFolding, ) from gunicorn.http.errors import InvalidProxyLine, ForbiddenProxyRequest from gunicorn.http.errors import InvalidSchemeHeaders @@ -110,10 +110,13 @@ def parse_headers(self, data, from_trailer=False): # b"\xDF".decode("latin-1").upper().encode("ascii") == b"SS" name = name.upper() - value = [value.lstrip(" \t")] + value = [value.strip(" \t")] - # Consume value continuation lines + # Consume value continuation lines.. while lines and lines[0].startswith((" ", "\t")): + # .. which is obsolete here, and no longer done by default + if not self.cfg.permit_obsolete_folding: + raise ObsoleteFolding(name) curr = lines.pop(0) header_length += len(curr) + len("\r\n") if header_length > self.limit_request_field_size > 0: diff --git a/gunicorn/workers/base.py b/gunicorn/workers/base.py index f97d923c7..bf0ce32ca 100644 --- a/gunicorn/workers/base.py +++ b/gunicorn/workers/base.py @@ -20,6 +20,8 @@ InvalidProxyLine, InvalidRequestLine, InvalidRequestMethod, InvalidSchemeHeaders, LimitRequestHeaders, LimitRequestLine, + UnsupportedTransferCoding, + ConfigurationProblem, ObsoleteFolding, ) from gunicorn.http.wsgi import Response, default_environ from gunicorn.reloader import reloader_engines @@ -210,7 +212,8 @@ def handle_error(self, req, client, addr, exc): InvalidHTTPVersion, InvalidHeader, InvalidHeaderName, LimitRequestLine, LimitRequestHeaders, InvalidProxyLine, ForbiddenProxyRequest, - InvalidSchemeHeaders, + InvalidSchemeHeaders, UnsupportedTransferCoding, + ConfigurationProblem, ObsoleteFolding, SSLError, )): @@ -223,6 +226,14 @@ def handle_error(self, req, client, addr, exc): mesg = "Invalid Method '%s'" % str(exc) elif isinstance(exc, InvalidHTTPVersion): mesg = "Invalid HTTP Version '%s'" % str(exc) + elif isinstance(exc, UnsupportedTransferCoding): + mesg = "%s" % str(exc) + status_int = 501 + elif isinstance(exc, ConfigurationProblem): + mesg = "%s" % str(exc) + status_int = 500 + elif isinstance(exc, ObsoleteFolding): + mesg = "%s" % str(exc) elif isinstance(exc, (InvalidHeaderName, InvalidHeader,)): mesg = "%s" % str(exc) if not req and hasattr(exc, "req"): diff --git a/tests/requests/invalid/013.py b/tests/requests/invalid/013.py index f54ebb1e1..acfcac0a2 100644 --- a/tests/requests/invalid/013.py +++ b/tests/requests/invalid/013.py @@ -4,3 +4,7 @@ request = LimitRequestHeaders cfg = Config() cfg.set('limit_request_field_size', 14) + +# once this option is removed, this test should not be dropped; +# rather, add something involving unnessessary padding +cfg.set('permit_obsolete_folding', True) diff --git a/tests/requests/invalid/obs_fold_01.http b/tests/requests/invalid/obs_fold_01.http new file mode 100644 index 000000000..d23b41750 --- /dev/null +++ b/tests/requests/invalid/obs_fold_01.http @@ -0,0 +1,5 @@ +GET / HTTP/1.1\r\n +Long: one\r\n + two\r\n +Host: localhost\r\n +\r\n diff --git a/tests/requests/invalid/obs_fold_01.py b/tests/requests/invalid/obs_fold_01.py new file mode 100644 index 000000000..2c03b466d --- /dev/null +++ b/tests/requests/invalid/obs_fold_01.py @@ -0,0 +1,3 @@ +from gunicorn.http.errors import ObsoleteFolding + +request = ObsoleteFolding diff --git a/tests/requests/valid/compat_obs_fold.http b/tests/requests/valid/compat_obs_fold.http new file mode 100644 index 000000000..d23b41750 --- /dev/null +++ b/tests/requests/valid/compat_obs_fold.http @@ -0,0 +1,5 @@ +GET / HTTP/1.1\r\n +Long: one\r\n + two\r\n +Host: localhost\r\n +\r\n diff --git a/tests/requests/valid/compat_obs_fold.py b/tests/requests/valid/compat_obs_fold.py new file mode 100644 index 000000000..ba41b7d47 --- /dev/null +++ b/tests/requests/valid/compat_obs_fold.py @@ -0,0 +1,16 @@ +from gunicorn.http.errors import ObsoleteFolding +from gunicorn.config import Config + +cfg = Config() +cfg.set('permit_obsolete_folding', True) + +request = { + "method": "GET", + "uri": uri("/"), + "version": (1, 1), + "headers": [ + ("LONG", "one two"), + ("HOST", "localhost"), + ], + "body": b"" +} diff --git a/tests/requests/valid/016.http b/tests/requests/valid/compat_obs_fold_huge.http similarity index 100% rename from tests/requests/valid/016.http rename to tests/requests/valid/compat_obs_fold_huge.http diff --git a/tests/requests/valid/016.py b/tests/requests/valid/compat_obs_fold_huge.py similarity index 95% rename from tests/requests/valid/016.py rename to tests/requests/valid/compat_obs_fold_huge.py index 4e5144f8c..a0f46c140 100644 --- a/tests/requests/valid/016.py +++ b/tests/requests/valid/compat_obs_fold_huge.py @@ -1,3 +1,8 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set('permit_obsolete_folding', True) + certificate = """-----BEGIN CERTIFICATE----- MIIFbTCCBFWgAwIBAgICH4cwDQYJKoZIhvcNAQEFBQAwcDELMAkGA1UEBhMCVUsx ETAPBgNVBAoTCGVTY2llbmNlMRIwEAYDVQQLEwlBdXRob3JpdHkxCzAJBgNVBAMT diff --git a/tests/requests/valid/padding_01.http b/tests/requests/valid/padding_01.http new file mode 100644 index 000000000..757208b08 --- /dev/null +++ b/tests/requests/valid/padding_01.http @@ -0,0 +1,4 @@ +GET / HTTP/1.1\r\n +Host: localhost\r\n +Name: \t value \t \r\n +\r\n diff --git a/tests/requests/valid/padding_01.py b/tests/requests/valid/padding_01.py new file mode 100644 index 000000000..d58f67207 --- /dev/null +++ b/tests/requests/valid/padding_01.py @@ -0,0 +1,11 @@ + +request = { + "method": "GET", + "uri": uri("/"), + "version": (1, 1), + "headers": [ + ("HOST", "localhost"), + ("NAME", "value") + ], + "body": b"", +}