17 Commits

Author SHA1 Message Date
abe90d3483 Advanced to version 1.5.4. 2023-05-18 00:06:16 +08:00
65e7dcdf6d Replaced the "/next" next URI with the NEXT_URI constant in the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-18 00:06:05 +08:00
74e414badf Removed unnecessary f-strings from the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-17 23:54:52 +08:00
69175979ff Added the form name to the dummy forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 22:56:47 +08:00
2f69e0f215 Added the form name to the search forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 21:43:21 +08:00
961385c389 Added SESSION_COOKIE_SAMESITE and SESSION_COOKIE_SECURE to create_app of the test site, to set the SameSite and Secure flags for the session cookie. 2023-05-17 19:57:38 +08:00
a691cfd2da Applied the or_next utility to the set local route of the test site. 2023-05-17 19:57:23 +08:00
482a0faa23 Added safeguard to the next URI utilities from invalid or insecure next URI. 2023-05-17 16:26:35 +08:00
0ecf7b6617 Revised the documentation of the "accounting.utils.cast" module. 2023-05-17 15:33:42 +08:00
4408bbfc82 Updated the JavaScript library versions, and added decimal.js-light to the documentation. 2023-05-06 23:59:06 +08:00
433110f486 Revised the way to query accounts with Flask-SQLAlchemy style queries in the accounts method of the CurrentAccount data model. 2023-05-04 09:35:20 +08:00
0b1dd4f4fc Advanced to version 1.5.3. 2023-04-30 15:07:46 +08:00
46bd27e126 Revised the saveOriginalLineItem method of the JavaScript JournalEntryLineItemEditor class not to override the existing amount when the existing amount is less than the net balance. This make it easier when updating the existing journal entries. 2023-04-30 15:03:59 +08:00
b718d19450 Resolved an issue where, in cases where there was no existing localized title and the default title was submitted, the submitted account title or currency name would be erroneously saved as the localized title. 2023-04-30 15:03:58 +08:00
2969e83afe Advanced to version 1.5.2. 2023-04-30 06:43:18 +08:00
a732656746 Revised the coding style in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:43 +08:00
1daed940b6 Corrected the definition of the "is_offset" property in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:01 +08:00
22 changed files with 143 additions and 41 deletions

View File

@ -2,6 +2,39 @@ Change Log
==========
Version 1.5.4
-------------
Released 2023/5/18
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
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
* Revised the original line item editor not to override the existing
amount when the existing amount is less or equal to the net
balance.
Version 1.5.2
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
Version 1.5.1
-------------

View File

@ -50,9 +50,9 @@ The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
* FontAwesome_ 6.4.0 or above
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.7.7 or above
Configuration
@ -114,6 +114,7 @@ Check your Flask application and see how it works.
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/

View File

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

View File

@ -182,6 +182,8 @@ class Account(db.Model):
:param value: The new title.
:return: None.
"""
if self.title == value:
return
if self.title_l10n is None:
self.title_l10n = value
return
@ -424,6 +426,8 @@ class Currency(db.Model):
:param value: The new name.
:return: None.
"""
if self.name == value:
return
if self.name_l10n is None:
self.name_l10n = value
return

View File

@ -123,15 +123,13 @@ class OffsetMatcher:
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in self.line_items:
line_item.is_offset = line_item.id in net_balances
self.unapplied = [x for x in self.line_items
if x.is_offset]
line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items if not x.is_offset]
for line_item in self.unapplied:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
self.unmatched = [x for x in self.line_items
if not x.is_offset]
self.unmatched = [x for x in self.line_items if x.is_offset]
self.__populate_accumulated_balances()
def __populate_accumulated_balances(self) -> None:

View File

@ -290,7 +290,9 @@ class JournalEntryLineItemEditor {
this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false;
this.#accountText.innerText = this.account.text;
if (this.#amountInput.value === "" || new Decimal(this.#amountInput.value).greaterThan(originalLineItem.netBalance)) {
this.#amountInput.value = String(originalLineItem.netBalance);
}
this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0";
this.#validate();

View File

@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }}
</a>
{% 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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %}
<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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }}
</a>
{% 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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<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)
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 class="modal-dialog">
<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)
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 class="modal-dialog">
<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)
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 class="modal-dialog">
<div class="modal-content">

View File

@ -19,7 +19,7 @@ search-modal.html: The search modal
Author: imacat@mail.imacat.idv.tw (imacat)
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-dialog">
<div class="modal-content">

View File

@ -118,7 +118,7 @@ First written: 2023/3/8
</button>
{% endif %}
{% 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">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

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

View File

@ -21,7 +21,6 @@ from typing import Self
import sqlalchemy as sa
from accounting import db
from accounting.locale import gettext
from accounting.models import Account
@ -75,7 +74,7 @@ class CurrentAccount:
"""
accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x)
for x in db.session.query(Account)
for x in Account.query
.filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)])
return accounts

View File

@ -41,11 +41,8 @@ def inherit_next(uri: str) -> str:
:param uri: The URI.
:return: The URI with the current next URI added at the query argument.
"""
next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next")
if next_uri is None:
return uri
return __set_next(uri, next_uri)
next_uri: str | None = __get_next_uri()
return uri if next_uri is None else __set_next(uri, next_uri)
def or_next(uri: str) -> str:
@ -54,9 +51,22 @@ def or_next(uri: str) -> str:
:param uri: The 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") \
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:

View File

@ -2181,14 +2181,14 @@ class JournalEntryReorderTestCase(unittest.TestCase):
response = self.client.post(
f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token,
"next": "/next",
"next": NEXT_URI,
f"{id_1}-no": "4",
f"{id_2}-no": "1",
f"{id_3}-no": "5",
f"{id_4}-no": "2",
f"{id_5}-no": "3"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next")
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 4)
@ -2209,12 +2209,12 @@ class JournalEntryReorderTestCase(unittest.TestCase):
response = self.client.post(
f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token,
"next": "/next",
"next": NEXT_URI,
f"{id_2}-no": "3a",
f"{id_3}-no": "5",
f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next")
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 3)

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"
app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",

View File

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

View File

@ -26,13 +26,13 @@ First written: 2023/1/27
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="{{ "imacat" }}" />
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
{% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/js/tempus-dominus.min.js" integrity="sha384-2MkID2vkc9sxBCqs2us3mB8fV+c0o7uPtOvAPjaC8gKv9Bk21UHT0r2Q7Kv70+zO" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/js/tempus-dominus.min.js" integrity="sha384-MxHp+/TqTjbku1jSTIe1e/4l6CZTLhACLDbWyxYaFRgD3AM4oh99AY8bxsGhIoRc" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
<link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}">
<title>{% block title %}{% endblock %}</title>

View File

@ -101,6 +101,60 @@ class NextUriTestCase(unittest.TestCase):
"name": "viewer"})
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):
"""The test case for the query keyword parser."""