- Copy missing tests over from distgit

- Backport fix for CVE-2023-30861
  Resolves: rhbz#2196683
This commit is contained in:
Brian C. Lane 2023-05-10 13:34:42 -07:00
parent 3d0b1b5c5b
commit d170540219
10 changed files with 2483 additions and 9 deletions

View File

@ -0,0 +1,182 @@
From 4195dc236d4286d6d82b3a40dc595a1437afcdc9 Mon Sep 17 00:00:00 2001
From: "Brian C. Lane" <bcl@redhat.com>
Date: Tue, 9 May 2023 15:13:34 -0700
Subject: [PATCH 4/5] Backport Vary: Cookie fix from upstream
This fixes CVE-2023-30861 by backporting the patch and tests from this
upstream commit:
https://github.com/pallets/flask/commit/70f906c51ce49c485f1d355703e9cc3386b1cc2b
Resolves: rhbz#2196683
---
flask/sessions.py | 6 +++
tests/test_basic.py | 90 ++++++++++++++++++++++++++++++++++++++++++++-
tests/test_ext.py | 2 +-
3 files changed, 95 insertions(+), 3 deletions(-)
diff --git a/flask/sessions.py b/flask/sessions.py
index 525ff246..2f60e7eb 100644
--- a/flask/sessions.py
+++ b/flask/sessions.py
@@ -338,6 +338,10 @@ class SecureCookieSessionInterface(SessionInterface):
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
+ # Add a "Vary: Cookie" header if the session was accessed at all.
+ if session.accessed:
+ response.vary.add("Cookie")
+
# Delete case. If there is no session we bail early.
# If the session was modified to be empty we remove the
# whole cookie.
@@ -345,6 +349,7 @@ class SecureCookieSessionInterface(SessionInterface):
if session.modified:
response.delete_cookie(app.session_cookie_name,
domain=domain, path=path)
+ response.vary.add("Cookie")
return
# Modification case. There are upsides and downsides to
@@ -364,3 +369,4 @@ class SecureCookieSessionInterface(SessionInterface):
response.set_cookie(app.session_cookie_name, val,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)
+ response.vary.add("Cookie")
diff --git a/tests/test_basic.py b/tests/test_basic.py
index c5ec9f5c..85555300 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -11,6 +11,7 @@
import pytest
+import os
import re
import uuid
import time
@@ -196,7 +197,8 @@ def test_session():
@app.route('/get')
def get():
- return flask.session['value']
+ v = flask.session.get("value", "None")
+ return v
c = app.test_client()
assert c.post('/set', data={'value': '42'}).data == b'value set'
@@ -333,7 +335,7 @@ def test_session_expiration():
client = app.test_client()
rv = client.get('/')
assert 'set-cookie' in rv.headers
- match = re.search(r'\bexpires=([^;]+)(?i)', rv.headers['set-cookie'])
+ match = re.search(r'(?i)\bexpires=([^;]+)', rv.headers['set-cookie'])
expires = parse_date(match.group())
expected = datetime.utcnow() + app.permanent_session_lifetime
assert expires.year == expected.year
@@ -445,6 +447,90 @@ def test_session_cookie_setting():
run_test(expect_header=False)
+def test_session_vary_cookie():
+ app = flask.Flask("flask_test", root_path=os.path.dirname(__file__))
+ app.config.update(
+ TESTING=True,
+ SECRET_KEY="test key",
+ )
+ client = app.test_client()
+
+ @app.route("/set")
+ def set_session():
+ flask.session["test"] = "test"
+ return ""
+ @app.route("/get")
+ def get():
+ return flask.session.get("test")
+ @app.route("/getitem")
+ def getitem():
+ return flask.session["test"]
+ @app.route("/setdefault")
+ def setdefault():
+ return flask.session.setdefault("test", "default")
+
+ @app.route("/clear")
+ def clear():
+ flask.session.clear()
+ return ""
+
+ @app.route("/vary-cookie-header-set")
+ def vary_cookie_header_set():
+ response = flask.Response()
+ response.vary.add("Cookie")
+ flask.session["test"] = "test"
+ return response
+ @app.route("/vary-header-set")
+ def vary_header_set():
+ response = flask.Response()
+ response.vary.update(("Accept-Encoding", "Accept-Language"))
+ flask.session["test"] = "test"
+ return response
+ @app.route("/no-vary-header")
+ def no_vary_header():
+ return ""
+ def expect(path, header_value="Cookie"):
+ rv = client.get(path)
+ if header_value:
+ # The 'Vary' key should exist in the headers only once.
+ assert len(rv.headers.get_all("Vary")) == 1
+ assert rv.headers["Vary"] == header_value
+ else:
+ assert "Vary" not in rv.headers
+ expect("/set")
+ expect("/get")
+ expect("/getitem")
+ expect("/setdefault")
+ expect("/clear")
+ expect("/vary-cookie-header-set")
+ expect("/vary-header-set", "Accept-Encoding, Accept-Language, Cookie")
+ expect("/no-vary-header", None)
+
+
+def test_session_refresh_vary():
+ app = flask.Flask("flask_test", root_path=os.path.dirname(__file__))
+ app.config.update(
+ TESTING=True,
+ SECRET_KEY="test key",
+ )
+ client = app.test_client()
+
+ @app.route("/login")
+ def login():
+ flask.session["user_id"] = 1
+ flask.session.permanent = True
+ return ""
+
+ @app.route("/ignored")
+ def ignored():
+ return ""
+
+ rv = client.get("/login")
+ assert rv.headers["Vary"] == "Cookie"
+ rv = client.get("/ignored")
+ assert rv.headers["Vary"] == "Cookie"
+
+
def test_flashes():
app = flask.Flask(__name__)
app.secret_key = 'testkey'
diff --git a/tests/test_ext.py b/tests/test_ext.py
index d336e404..c3e23cdf 100644
--- a/tests/test_ext.py
+++ b/tests/test_ext.py
@@ -180,7 +180,7 @@ def test_no_error_swallowing(flaskext_broken):
with pytest.raises(ImportError) as excinfo:
import flask.ext.broken
- assert excinfo.type is ImportError
+ assert excinfo.type in (ImportError, ModuleNotFoundError)
if PY2:
message = 'No module named missing_module'
else:
--
2.40.1

View File

@ -0,0 +1,107 @@
From 640ff67e9d59d7acd786683bbab422d8aa17211c Mon Sep 17 00:00:00 2001
From: "Brian C. Lane" <bcl@redhat.com>
Date: Tue, 9 May 2023 15:36:11 -0700
Subject: [PATCH 5/5] Backport support for the accessed attribute
This is added to the SessionMixin and SecureCookieSession to support
fixing CVE-2023-30861.
Related: rhbz#2196683
---
flask/sessions.py | 40 +++++++++++++++++++++++++++++++++++++++-
tests/test_basic.py | 8 ++++++++
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/flask/sessions.py b/flask/sessions.py
index 2f60e7eb..278c3583 100644
--- a/flask/sessions.py
+++ b/flask/sessions.py
@@ -48,6 +48,11 @@ class SessionMixin(object):
#: The default mixin implementation just hardcodes ``True`` in.
modified = True
+ #: Some implementations can detect when session data is read or
+ #: written and set this when that happens. The mixin default is hard
+ #: coded to ``True``.
+ accessed = True
+
def _tag(value):
if isinstance(value, tuple):
@@ -111,14 +116,47 @@ session_json_serializer = TaggedJSONSerializer()
class SecureCookieSession(CallbackDict, SessionMixin):
- """Base class for sessions based on signed cookies."""
+ """Base class for sessions based on signed cookies.
+
+ This session backend will set the :attr:`modified` and
+ :attr:`accessed` attributes. It cannot reliably track whether a
+ session is new (vs. empty), so :attr:`new` remains hard coded to
+ ``False``.
+ """
+
+ #: When data is changed, this is set to ``True``. Only the session
+ #: dictionary itself is tracked; if the session contains mutable
+ #: data (for example a nested dict) then this must be set to
+ #: ``True`` manually when modifying that data. The session cookie
+ #: will only be written to the response if this is ``True``.
+ modified = False
+
+ #: When data is read or written, this is set to ``True``. Used by
+ # :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie``
+ #: header, which allows caching proxies to cache different pages for
+ #: different users.
+ accessed = False
+
def __init__(self, initial=None):
def on_update(self):
self.modified = True
+ self.accessed = True
CallbackDict.__init__(self, initial, on_update)
self.modified = False
+ def __getitem__(self, key):
+ self.accessed = True
+ return super().__getitem__(key)
+
+ def get(self, key, default=None):
+ self.accessed = True
+ return super().get(key, default)
+
+ def setdefault(self, key, default=None):
+ self.accessed = True
+ return super().setdefault(key, default)
+
class NullSession(SecureCookieSession):
"""Class used to generate nicer error messages if sessions are not
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 85555300..5ad8c3de 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -192,12 +192,20 @@ def test_session():
@app.route('/set', methods=['POST'])
def set():
+ assert not flask.session.accessed
+ assert not flask.session.modified
flask.session['value'] = flask.request.form['value']
+ assert flask.session.accessed
+ assert flask.session.modified
return 'value set'
@app.route('/get')
def get():
+ assert not flask.session.accessed
+ assert not flask.session.modified
v = flask.session.get("value", "None")
+ assert flask.session.accessed
+ assert not flask.session.modified
return v
c = app.test_client()
--
2.40.1

1
EMPTY
View File

@ -1 +0,0 @@

View File

@ -10,7 +10,7 @@
Name: python-%{modname}
Version: 0.12.2
Release: 4%{?dist}
Release: 5%{?dist}
Epoch: 1
Summary: A micro-framework for Python based on Werkzeug, Jinja 2 and good intentions
@ -28,6 +28,11 @@ Patch0001: 0001-detect-UTF-encodings-when-loading-json.patch
Patch0002: 0002-Fix-ValueError-for-some-invalid-Range-requests.patch
Patch0003: 0003-be-smarter-about-adding-.cli-to-reloader-command.patch
# rhbz#2196683
# Backport fix for CVE-2023-30861
Patch0004: 0004-Backport-Vary-Cookie-fix-from-upstream.patch
Patch0005: 0005-Backport-support-for-the-accessed-attribute.patch
BuildArch: noarch
%global _description \
@ -71,7 +76,7 @@ Requires: python-itsdangerous
%description -n python2-%{modname} %{_description}
Python 2 version.
%endif # with python2
%endif
%package -n python%{python3_pkgversion}-%{modname}
Summary: %{summary}
@ -108,7 +113,7 @@ rm -rf examples/minitwit/
%build
%if %{with python2}
%py2_build
%endif # with python2
%endif
%py3_build
PYTHONPATH=`pwd` sphinx-build-3 -b html docs/ docs/_build/html/
rm -rf docs/_build/html/{.buildinfo,.doctrees}
@ -118,7 +123,7 @@ rm -rf docs/_build/html/{.buildinfo,.doctrees}
%py2_install
mv %{buildroot}%{_bindir}/%{modname}{,-%{python2_version}}
ln -s %{modname}-%{python2_version} %{buildroot}%{_bindir}/%{modname}-2
%endif # with python2
%endif
%py3_install
mv %{buildroot}%{_bindir}/%{modname}{,-%{python3_version}}
@ -128,13 +133,13 @@ ln -s %{modname}-%{python3_version} %{buildroot}%{_bindir}/%{modname}-3
ln -sf %{modname}-2 %{buildroot}%{_bindir}/%{modname}
%else
ln -sf %{modname}-3 %{buildroot}%{_bindir}/%{modname}
%endif # with python2
%endif
%check
export LC_ALL=C.UTF-8
%if %{with python2}
PYTHONPATH=%{buildroot}%{python2_sitelib} py.test-%{python2_version} -v
%endif # with python2
%endif
PYTHONPATH=%{buildroot}%{python3_sitelib} py.test-%{python3_version} -v || :
%if %{with python2}
@ -147,7 +152,7 @@ PYTHONPATH=%{buildroot}%{python3_sitelib} py.test-%{python3_version} -v || :
%{python2_sitelib}/%{modname}/
%{_bindir}/%{modname}
%endif # with python2
%endif
%files -n python%{python3_pkgversion}-%{modname}
%license LICENSE
@ -159,13 +164,18 @@ PYTHONPATH=%{buildroot}%{python3_sitelib} py.test-%{python3_version} -v || :
%if %{without python2}
%{_bindir}/%{modname}
%endif # without python2
%endif
%files doc
%license LICENSE
%doc docs/_build/html examples
%changelog
* Wed May 10 2023 Brian C. Lane <bcl@redhat.com> - 0.12.2-5
- Copy missing tests over from distgit
- Backport fix for CVE-2023-30861
Resolves: rhbz#2196683
* Thu Nov 07 2019 Brian C. Lane <bcl@redhat.com> - 0.12.2-4
- Add upstream changes from 0.12.4
Resolves: rhbz#1585318

4
tests/scripts/run_tests.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/bash
set -eux
# NOTE: test_helpers only contains the TestJSON class from upstream
pytest-3 ./test_basic.py ./test_helpers.py

View File

@ -0,0 +1,4 @@
{
"TEST_KEY": "foo",
"SECRET_KEY": "devkey"
}

View File

@ -0,0 +1 @@
<h1>Hello World!</h1>

1794
tests/scripts/test_basic.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
"""
tests.helpers
~~~~~~~~~~~~~~~~~~~~~~~
Various helpers.
:copyright: (c) 2015 by Armin Ronacher.
:license: BSD, see LICENSE for more details.
"""
import pytest
import uuid
import datetime
import flask
from werkzeug.exceptions import BadRequest
from werkzeug.http import http_date
from flask import json
from flask._compat import StringIO, text_type
def has_encoding(name):
try:
import codecs
codecs.lookup(name)
return True
except LookupError:
return False
class TestJSON(object):
@pytest.mark.parametrize('value', (
1, 't', True, False, None,
[], [1, 2, 3],
{}, {'foo': u'🐍'},
))
@pytest.mark.parametrize('encoding', (
'utf-8', 'utf-8-sig',
'utf-16-le', 'utf-16-be', 'utf-16',
'utf-32-le', 'utf-32-be', 'utf-32',
))
def test_detect_encoding(self, value, encoding):
data = json.dumps(value).encode(encoding)
assert json.detect_encoding(data) == encoding
assert json.loads(data) == value
def test_ignore_cached_json(self):
app = flask.Flask(__name__)
with app.test_request_context('/', method='POST', data='malformed',
content_type='application/json'):
assert flask.request.get_json(silent=True, cache=True) is None
with pytest.raises(BadRequest):
flask.request.get_json(silent=False, cache=False)
def test_post_empty_json_adds_exception_to_response_content_in_debug(self):
app = flask.Flask(__name__)
app.config['DEBUG'] = True
@app.route('/json', methods=['POST'])
def post_json():
flask.request.get_json()
return None
c = app.test_client()
rv = c.post('/json', data=None, content_type='application/json')
assert rv.status_code == 400
assert b'Failed to decode JSON object' in rv.data
def test_post_empty_json_wont_add_exception_to_response_if_no_debug(self):
app = flask.Flask(__name__)
app.config['DEBUG'] = False
@app.route('/json', methods=['POST'])
def post_json():
flask.request.get_json()
return None
c = app.test_client()
rv = c.post('/json', data=None, content_type='application/json')
assert rv.status_code == 400
assert b'Failed to decode JSON object' not in rv.data
def test_json_bad_requests(self):
app = flask.Flask(__name__)
@app.route('/json', methods=['POST'])
def return_json():
return flask.jsonify(foo=text_type(flask.request.get_json()))
c = app.test_client()
rv = c.post('/json', data='malformed', content_type='application/json')
assert rv.status_code == 400
def test_json_custom_mimetypes(self):
app = flask.Flask(__name__)
@app.route('/json', methods=['POST'])
def return_json():
return flask.request.get_json()
c = app.test_client()
rv = c.post('/json', data='"foo"', content_type='application/x+json')
assert rv.data == b'foo'
def test_json_as_unicode(self):
app = flask.Flask(__name__)
app.config['JSON_AS_ASCII'] = True
with app.app_context():
rv = flask.json.dumps(u'\N{SNOWMAN}')
assert rv == '"\\u2603"'
app.config['JSON_AS_ASCII'] = False
with app.app_context():
rv = flask.json.dumps(u'\N{SNOWMAN}')
assert rv == u'"\u2603"'
def test_json_dump_to_file(self):
app = flask.Flask(__name__)
test_data = {'name': 'Flask'}
out = StringIO()
with app.app_context():
flask.json.dump(test_data, out)
out.seek(0)
rv = flask.json.load(out)
assert rv == test_data
@pytest.mark.parametrize('test_value', [0, -1, 1, 23, 3.14, 's', "longer string", True, False, None])
def test_jsonify_basic_types(self, test_value):
"""Test jsonify with basic types."""
app = flask.Flask(__name__)
c = app.test_client()
url = '/jsonify_basic_types'
app.add_url_rule(url, url, lambda x=test_value: flask.jsonify(x))
rv = c.get(url)
assert rv.mimetype == 'application/json'
assert flask.json.loads(rv.data) == test_value
def test_jsonify_dicts(self):
"""Test jsonify with dicts and kwargs unpacking."""
d = dict(
a=0, b=23, c=3.14, d='t', e='Hi', f=True, g=False,
h=['test list', 10, False],
i={'test':'dict'}
)
app = flask.Flask(__name__)
@app.route('/kw')
def return_kwargs():
return flask.jsonify(**d)
@app.route('/dict')
def return_dict():
return flask.jsonify(d)
c = app.test_client()
for url in '/kw', '/dict':
rv = c.get(url)
assert rv.mimetype == 'application/json'
assert flask.json.loads(rv.data) == d
def test_jsonify_arrays(self):
"""Test jsonify of lists and args unpacking."""
l = [
0, 42, 3.14, 't', 'hello', True, False,
['test list', 2, False],
{'test':'dict'}
]
app = flask.Flask(__name__)
@app.route('/args_unpack')
def return_args_unpack():
return flask.jsonify(*l)
@app.route('/array')
def return_array():
return flask.jsonify(l)
c = app.test_client()
for url in '/args_unpack', '/array':
rv = c.get(url)
assert rv.mimetype == 'application/json'
assert flask.json.loads(rv.data) == l
def test_jsonify_date_types(self):
"""Test jsonify with datetime.date and datetime.datetime types."""
test_dates = (
datetime.datetime(1973, 3, 11, 6, 30, 45),
datetime.date(1975, 1, 5)
)
app = flask.Flask(__name__)
c = app.test_client()
for i, d in enumerate(test_dates):
url = '/datetest{0}'.format(i)
app.add_url_rule(url, str(i), lambda val=d: flask.jsonify(x=val))
rv = c.get(url)
assert rv.mimetype == 'application/json'
assert flask.json.loads(rv.data)['x'] == http_date(d.timetuple())
def test_jsonify_uuid_types(self):
"""Test jsonify with uuid.UUID types"""
test_uuid = uuid.UUID(bytes=b'\xDE\xAD\xBE\xEF' * 4)
app = flask.Flask(__name__)
url = '/uuid_test'
app.add_url_rule(url, url, lambda: flask.jsonify(x=test_uuid))
c = app.test_client()
rv = c.get(url)
rv_x = flask.json.loads(rv.data)['x']
assert rv_x == str(test_uuid)
rv_uuid = uuid.UUID(rv_x)
assert rv_uuid == test_uuid
def test_json_attr(self):
app = flask.Flask(__name__)
@app.route('/add', methods=['POST'])
def add():
json = flask.request.get_json()
return text_type(json['a'] + json['b'])
c = app.test_client()
rv = c.post('/add', data=flask.json.dumps({'a': 1, 'b': 2}),
content_type='application/json')
assert rv.data == b'3'
def test_template_escaping(self):
app = flask.Flask(__name__)
render = flask.render_template_string
with app.test_request_context():
rv = flask.json.htmlsafe_dumps('</script>')
assert rv == u'"\\u003c/script\\u003e"'
assert type(rv) == text_type
rv = render('{{ "</script>"|tojson }}')
assert rv == '"\\u003c/script\\u003e"'
rv = render('{{ "<\0/script>"|tojson }}')
assert rv == '"\\u003c\\u0000/script\\u003e"'
rv = render('{{ "<!--<script>"|tojson }}')
assert rv == '"\\u003c!--\\u003cscript\\u003e"'
rv = render('{{ "&"|tojson }}')
assert rv == '"\\u0026"'
rv = render('{{ "\'"|tojson }}')
assert rv == '"\\u0027"'
rv = render("<a ng-data='{{ data|tojson }}'></a>",
data={'x': ["foo", "bar", "baz'"]})
assert rv == '<a ng-data=\'{"x": ["foo", "bar", "baz\\u0027"]}\'></a>'
def test_json_customization(self):
class X(object):
def __init__(self, val):
self.val = val
class MyEncoder(flask.json.JSONEncoder):
def default(self, o):
if isinstance(o, X):
return '<%d>' % o.val
return flask.json.JSONEncoder.default(self, o)
class MyDecoder(flask.json.JSONDecoder):
def __init__(self, *args, **kwargs):
kwargs.setdefault('object_hook', self.object_hook)
flask.json.JSONDecoder.__init__(self, *args, **kwargs)
def object_hook(self, obj):
if len(obj) == 1 and '_foo' in obj:
return X(obj['_foo'])
return obj
app = flask.Flask(__name__)
app.testing = True
app.json_encoder = MyEncoder
app.json_decoder = MyDecoder
@app.route('/', methods=['POST'])
def index():
return flask.json.dumps(flask.request.get_json()['x'])
c = app.test_client()
rv = c.post('/', data=flask.json.dumps({
'x': {'_foo': 42}
}), content_type='application/json')
assert rv.data == b'"<42>"'
def test_modified_url_encoding(self):
class ModifiedRequest(flask.Request):
url_charset = 'euc-kr'
app = flask.Flask(__name__)
app.testing = True
app.request_class = ModifiedRequest
app.url_map.charset = 'euc-kr'
@app.route('/')
def index():
return flask.request.args['foo']
rv = app.test_client().get(u'/?foo=정상처리'.encode('euc-kr'))
assert rv.status_code == 200
assert rv.data == u'정상처리'.encode('utf-8')
if not has_encoding('euc-kr'):
test_modified_url_encoding = None
def test_json_key_sorting(self):
app = flask.Flask(__name__)
app.testing = True
assert app.config['JSON_SORT_KEYS'] == True
d = dict.fromkeys(range(20), 'foo')
@app.route('/')
def index():
return flask.jsonify(values=d)
c = app.test_client()
rv = c.get('/')
lines = [x.strip() for x in rv.data.strip().decode('utf-8').splitlines()]
sorted_by_str = [
'{',
'"values": {',
'"0": "foo",',
'"1": "foo",',
'"10": "foo",',
'"11": "foo",',
'"12": "foo",',
'"13": "foo",',
'"14": "foo",',
'"15": "foo",',
'"16": "foo",',
'"17": "foo",',
'"18": "foo",',
'"19": "foo",',
'"2": "foo",',
'"3": "foo",',
'"4": "foo",',
'"5": "foo",',
'"6": "foo",',
'"7": "foo",',
'"8": "foo",',
'"9": "foo"',
'}',
'}'
]
sorted_by_int = [
'{',
'"values": {',
'"0": "foo",',
'"1": "foo",',
'"2": "foo",',
'"3": "foo",',
'"4": "foo",',
'"5": "foo",',
'"6": "foo",',
'"7": "foo",',
'"8": "foo",',
'"9": "foo",',
'"10": "foo",',
'"11": "foo",',
'"12": "foo",',
'"13": "foo",',
'"14": "foo",',
'"15": "foo",',
'"16": "foo",',
'"17": "foo",',
'"18": "foo",',
'"19": "foo"',
'}',
'}'
]
try:
assert lines == sorted_by_int
except AssertionError:
assert lines == sorted_by_str

15
tests/tests.yml Normal file
View File

@ -0,0 +1,15 @@
---
# Run a simple test
- hosts: localhost
roles:
- role: standard-test-basic
tags:
- classic
required_packages:
- python3-pytest
tests:
- simple:
dir: scripts
run: ./run_tests.sh