Compare commits

...

7 Commits

15 changed files with 100 additions and 21 deletions

View File

@ -2,6 +2,20 @@ Change Log
========== ==========
Version 1.5.4
-------------
Released 2023/5/17
Security fixes.
* Added safeguard to the next URI utilities, to prevent Cross-Site
Scripting (XSS) attacks.
* Applied the safe next URI utilities to the test site.
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
of the test site.
Version 1.5.3 Version 1.5.3
------------- -------------

View File

@ -24,7 +24,7 @@ from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.5.3" VERSION: str = "1.5.4"
"""The package version.""" """The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""

View File

@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %} {% block content %}
<div class="mb-2 accounting-toolbar"> <div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -19,7 +19,7 @@ description-editor-modal.html: The modal of the description editor
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28 First written: 2023/2/28
#} #}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}"> <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" name="accounting-dummy-form" data-debit-credit="{{ description_editor.debit_credit }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -19,7 +19,7 @@ journal-entry-line-item-editor-modal: The modal of the journal entry line item e
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
<form id="accounting-line-item-editor"> <form id="accounting-line-item-editor" name="accounting-dummy-form">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -19,7 +19,7 @@ recurring-item-editor-modal.html: The modal of the recurring item editor
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22 First written: 2023/3/22
#} #}
<form id="accounting-recurring-item-editor-{{ expense_income }}"> <form id="accounting-recurring-item-editor-{{ expense_income }}" name="accounting-dummy-form">
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true"> <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -19,7 +19,7 @@ search-modal.html: The search modal
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<form action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label"> <form action="{{ url_for("accounting-report.search") }}" name="accounting-search-form" method="get" role="search" aria-labelledby="accounting-search-modal-label">
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true"> <div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -118,7 +118,7 @@ First written: 2023/3/8
</button> </button>
{% endif %} {% endif %}
{% if use_search %} {% if use_search %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -14,8 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The utility to cast a SQLAlchemy column into the column type, to avoid """The utilities to cast values into desired types, to avoid IDE warnings.
warnings from the IDE.
This module should not import any other module from the application. This module should not import any other module from the application.

View File

@ -41,11 +41,8 @@ def inherit_next(uri: str) -> str:
:param uri: The URI. :param uri: The URI.
:return: The URI with the current next URI added at the query argument. :return: The URI with the current next URI added at the query argument.
""" """
next_uri: str | None = request.form.get("next") \ next_uri: str | None = __get_next_uri()
if request.method == "POST" else request.args.get("next") return uri if next_uri is None else __set_next(uri, next_uri)
if next_uri is None:
return uri
return __set_next(uri, next_uri)
def or_next(uri: str) -> str: def or_next(uri: str) -> str:
@ -54,9 +51,22 @@ def or_next(uri: str) -> str:
:param uri: The URI. :param uri: The URI.
:return: The next URI or the supplied URI. :return: The next URI or the supplied URI.
""" """
next_uri: str | None = __get_next_uri()
return uri if next_uri is None else next_uri
def __get_next_uri() -> str | None:
"""Returns the valid next URI.
:return: The valid next URI.
"""
next_uri: str | None = request.form.get("next") \ next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next") if request.method == "POST" else request.args.get("next")
return uri if next_uri is None else next_uri if next_uri is None or not next_uri.startswith("/"):
return None
if len(next_uri) > 512:
return next_uri[:512]
return next_uri
def __set_next(uri: str, next_uri: str) -> str: def __set_next(uri: str, next_uri: str) -> str:

View File

@ -52,6 +52,8 @@ def create_app(is_testing: bool = False) -> Flask:
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite" db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
app.config.from_mapping({ app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)), "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri, "SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en", "BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",

View File

@ -19,10 +19,12 @@
""" """
from babel import Locale from babel import Locale
from flask import request, session, current_app, Blueprint, Response, \ from flask import request, session, current_app, Blueprint, Response, \
redirect, url_for, Flask redirect, Flask
from flask_babel import Babel from flask_babel import Babel
from werkzeug.datastructures import LanguageAccept from werkzeug.datastructures import LanguageAccept
from accounting.utils.next_uri import or_next
bp: Blueprint = Blueprint("locale", __name__, url_prefix="/") bp: Blueprint = Blueprint("locale", __name__, url_prefix="/")
@ -68,9 +70,7 @@ def set_locale() -> Response:
all_linguas: dict[str, str] = get_all_linguas() all_linguas: dict[str, str] = get_all_linguas()
if "locale" in request.form and request.form["locale"] in all_linguas: if "locale" in request.form and request.form["locale"] in all_linguas:
session["locale"] = request.form["locale"] session["locale"] = request.form["locale"]
if "next" in request.form: return redirect(or_next("/"))
return redirect(request.form["next"])
return redirect(url_for("home.home"))
def get_all_linguas() -> dict[str, str]: def get_all_linguas() -> dict[str, str]:

View File

@ -101,6 +101,60 @@ class NextUriTestCase(unittest.TestCase):
"name": "viewer"}) "name": "viewer"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_invalid(self) -> None:
"""Tests the next URI utilities without an invalid next URI.
:return: None.
"""
def test_invalid_next_uri_view() -> str:
"""The test view without the next URI."""
self.assertEqual(inherit_next(self.TARGET),
request.args.get("inherit-expected"))
self.assertEqual(or_next(self.TARGET),
request.args.get("or-expected"))
return ""
self.app.add_url_rule("/test-invalid-next",
view_func=test_invalid_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
next_uri: str
expected1: str
expected2: str
response: httpx.Response
# A foreign URI
next_uri = "https://example.com"
expected1 = self.TARGET
expected2 = self.TARGET
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}"
f"&inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next"
f"?inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}",
data={"csrf_token": csrf_token,
"next": next_uri})
self.assertEqual(response.status_code, 200)
# An extremely-long URI to trigger the error
next_uri = "/" + "x" * 1024
expected2 = next_uri[:512]
expected1 = f"{self.TARGET}?next={quote_plus(expected2)}"
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}"
f"&inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next"
f"?inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}",
data={"csrf_token": csrf_token,
"next": next_uri})
self.assertEqual(response.status_code, 200)
class QueryKeywordParserTestCase(unittest.TestCase): class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser.""" """The test case for the query keyword parser."""