Compare commits
7 Commits
4408bbfc82
...
b1a7c8c325
Author | SHA1 | Date | |
---|---|---|---|
b1a7c8c325 | |||
69175979ff | |||
2f69e0f215 | |||
961385c389 | |||
a691cfd2da | |||
482a0faa23 | |||
0ecf7b6617 |
@ -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
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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|简体中文",
|
||||||
|
@ -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]:
|
||||||
|
@ -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."""
|
||||||
|
Loading…
Reference in New Issue
Block a user