Skip to content

Commit f68c76b

Browse files
authored
Merge pull request #2098 from pbiering/regression-fixes
Regression fixes
2 parents 4eb0935 + ce21a39 commit f68c76b

26 files changed

Lines changed: 745 additions & 84 deletions

.github/workflows/test.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ jobs:
8585
- name: Test with newest Python on latest Ubuntu using VFAT
8686
run: tox -c pyproject.toml -e py_filesystem_vfat
8787

88+
test-ubuntu-python-newest-with-vfat-utf8:
89+
name: Test VFAT UTF-8 Python:newest Ubuntu:latest
90+
needs: [lint, test-ubuntu-python-newest, test-ubuntu-python-newest-with-vfat]
91+
strategy:
92+
matrix:
93+
os: [ubuntu-latest]
94+
python-version: ['3.14']
95+
runs-on: ${{ matrix.os }}
96+
steps:
97+
- uses: actions/checkout@v5
98+
- uses: actions/setup-python@v6
99+
with:
100+
python-version: ${{ matrix.python-version }}
101+
- name: Install Test dependencies
102+
run: pip install tox
103+
- name: Test with newest Python on latest Ubuntu using VFAT UTF-8
104+
run: tox -c pyproject.toml -e py_filesystem_vfat_utf8
105+
88106
test-ubuntu-python-newest-with-ntfs:
89107
name: Test NTFS Python:newest Ubuntu:latest
90108
needs: [lint, test-ubuntu-python-newest]
@@ -163,7 +181,7 @@ jobs:
163181

164182
test-otheros-python-newest:
165183
name: Test MacOS/Windows:latest Python:newest
166-
needs: [lint, test-ubuntu-python-newest, test-ubuntu-python-newest-with-ntfs, test-ubuntu-python-newest-with-vfat]
184+
needs: [lint, test-ubuntu-python-newest, test-ubuntu-python-newest-with-ntfs, test-ubuntu-python-newest-with-vfat, test-ubuntu-python-newest-with-vfat-utf8]
167185
strategy:
168186
matrix:
169187
os: [macos-latest, windows-latest]
@@ -310,7 +328,7 @@ jobs:
310328
- uses: actions/checkout@v5
311329
- uses: actions/setup-python@v6
312330
with:
313-
python-version: '3.12'
331+
python-version: '3.14'
314332
- name: Install uv
315333
run: pip install uv
316334
- name: Install Playwright Browsers

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
* Improve: application will stop on startup if TEMP is provided but not existing or not writable
99
* Extension: tox with new optional test cases to test with LinuxOS vfat, hfsplus, ntfs filesystems
1010
* Adjust: respond with 500 in case principal collection cannot be created (e.g. filesystem issues)
11+
* Improve: sharing supports now unicode
12+
* Add: [server] new options validate_user_value/validate_path_value for ability to block unwanted values (autoenable "strict" on non-unicode filesystem)
13+
* Adjust: several log levels incl. final result depending on status code
14+
* Fix: sharing/csv: quote handling and honor stock encoding
15+
* Extension: sharing: add ICAL:calendar-order to property overlay whitelist
1116

1217
## 3.7.1
1318
* Fix: share address book collection as birthday calendar not working on non-DEBUG level

DOCUMENTATION.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,35 @@ Strict preconditions check on PUT in case item already exists [RFC6352#9.2](http
16171617
16181618
Default: `False`
16191619
1620+
##### validate_user_value
1621+
1622+
_(>= 3.7.2)_
1623+
1624+
Validate user value content
1625+
1626+
Available types are:
1627+
* `none`
1628+
* `minimal` (control and some special chars)
1629+
* `unicode-letter` (unicode letters)
1630+
* `no-unicode` (no unicode)
1631+
* `strict` (reduced ASCII set)
1632+
1633+
Default: `minimum`
1634+
1635+
##### validate_path_type
1636+
1637+
_(>= 3.7.2)_
1638+
1639+
Validate path value content
1640+
1641+
* `none`
1642+
* `minimal` (control and some special chars)
1643+
* `unicode-letter` (unicode letters)
1644+
* `no-unicode` (no unicode)
1645+
* `strict` (reduced ASCII set)
1646+
1647+
Default: `minimum`
1648+
16201649
##### hook
16211650
16221651
Command that is run after changes to storage. See the

config

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@
5858
# script name to strip from URI if called by reverse proxy
5959
#script_name = (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)
6060

61+
# validate user type
62+
# Value: none|minimal|unicode-letter|no-unicode|strict
63+
#validate_user_value = minimal
64+
65+
# validate path value
66+
# Value: none|minimal|unicode-letter|no-unicode|strict
67+
#validate_path_value = minimal
68+
6169

6270
[encoding]
6371

@@ -424,6 +432,7 @@
424432
# This may become the default in future versions, override if you need a different CSP.
425433
Content-Security-Policy = default-src 'self'; object-src 'none'
426434

435+
427436
[hook]
428437

429438
# Hook types

pyproject.toml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ commands_pre = [
101101
# unconditionally create mount point
102102
["mkdir", "-p", "/tmp/vfat"],
103103
# mount image
104-
["sudo", "/usr/bin/mount", "-o", "loop,umask=000", "/tmp/vfat.img", "/tmp/vfat"],
104+
["sudo", "/usr/bin/mount", "-o", "loop,umask=000,utf8=no", "/tmp/vfat.img", "/tmp/vfat"],
105105
]
106106
setenv = { TEMP = "/tmp/vfat" }
107107
commands = [["pytest", "-r", "s", "."]]
@@ -114,6 +114,33 @@ commands_post = [
114114
["rm", "/tmp/vfat.img"]
115115
]
116116

117+
[tool.tox.env.py_filesystem_vfat_utf8]
118+
allowlist_externals = [ "sudo", "dd", "mkfs.vfat", "mkdir", "rm", "rmdir", "chmod" ]
119+
extras = ["test"]
120+
deps = [
121+
"pytest"
122+
]
123+
commands_pre = [
124+
# create 64 MByte disk image
125+
["dd", "if=/dev/zero", "of=/tmp/vfat-utf8.img", "bs=1M", "count=64"],
126+
# create file system
127+
["mkfs.vfat", "/tmp/vfat-utf8.img", "-n", "VFAT"],
128+
# unconditionally create mount point
129+
["mkdir", "-p", "/tmp/vfat-utf8"],
130+
# mount image
131+
["sudo", "/usr/bin/mount", "-o", "loop,umask=000,utf8", "/tmp/vfat-utf8.img", "/tmp/vfat-utf8"],
132+
]
133+
setenv = { TEMP = "/tmp/vfat-utf8" }
134+
commands = [["pytest", "-r", "s", "."]]
135+
commands_post = [
136+
# umount image
137+
["sudo", "/usr/bin/umount", "-d", "/tmp/vfat-utf8"],
138+
# remove mount point
139+
["rmdir", "/tmp/vfat-utf8"],
140+
# remove image
141+
["rm", "/tmp/vfat-utf8.img"]
142+
]
143+
117144
[tool.tox.env.py_filesystem_hfsplus]
118145
# Fedora
119146
allowlist_externals = [ "sudo", "dd", "mkfs.hfsplus", "mkdir", "rm", "rmdir", "chmod" ]

radicale/app/__init__.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from typing import Iterable, List, Mapping, Tuple, Union
4343

4444
from radicale import config, httputils, log, pathutils, types, utils
45+
from radicale.app import base as app_base
4546
from radicale.app.base import ApplicationBase
4647
from radicale.app.delete import ApplicationPartDelete
4748
from radicale.app.get import ApplicationPartGet
@@ -86,6 +87,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
8687
_profiling_per_request: bool = False
8788
_profiling_per_request_method: bool = False
8889
_limit_content: int
90+
_validate_user_value: str
91+
_validate_path_value: str
8992
profiler_per_request_method: dict[str, cProfile.Profile] = {}
9093
profiler_per_request_method_counter: dict[str, int] = {}
9194
profiler_per_request_method_starttime: datetime.datetime
@@ -158,6 +161,19 @@ def __init__(self, configuration: config.Configuration) -> None:
158161
self._extra_headers[key] = configuration.get("headers", key)
159162
self._strict_preconditions = configuration.get("storage", "strict_preconditions")
160163
logger.info("strict preconditions check: %s", self._strict_preconditions)
164+
# Format checks
165+
self._validate_user_value = configuration.get("server", "validate_user_value")
166+
self._validate_path_value = configuration.get("server", "validate_path_value")
167+
if not self._storage._supports_unicode:
168+
if self._validate_user_value not in ["strict", "no-unicode"]:
169+
self._validate_user_value = "no-unicode"
170+
if self._validate_path_value not in ["strict", "no-unicode"]:
171+
self._validate_path_value = "no-unicode"
172+
logger.notice("validate user value: %r (enforced by limited support of collection storage)", self._validate_user_value)
173+
logger.notice("validate path value: %r (enforced by limited support of collection storage)", self._validate_path_value)
174+
else:
175+
logger.info("validate user value: %r", self._validate_user_value)
176+
logger.info("validate path value: %r", self._validate_path_value)
161177
# Profiling options
162178
self._profiling = configuration.get("logging", "profiling")
163179
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
@@ -338,15 +354,23 @@ def response(status: int, headers: types.WSGIResponseHeaders,
338354
else:
339355
flags_text = ""
340356
if answer is not None:
341-
logger.info("%s response status for %r%s in %.3f seconds %s %s bytes%s: %s",
357+
message = "%s response status for %r%s in %.3f seconds %s %s bytes%s: %s" % (
342358
request_method, unsafe_path, depthinfo,
343359
time_delta_seconds, content_encoding, str(len(answer)),
344360
flags_text,
345361
status_text)
346362
else:
347-
logger.info("%s response status for %r%s in %.3f seconds: %s",
363+
message = "%s response status for %r%s in %.3f seconds: %s" % (
348364
request_method, unsafe_path, depthinfo,
349365
time_delta_seconds, status_text)
366+
logger_method = logger.info # default
367+
if status == 401 or status == 404 or status == 412 or status == 409 or request_method in ["PROPPATCH", "MKCALENDAR", "MKCOL"]:
368+
logger_method = logger.notice
369+
elif status >= 500 and status < 500:
370+
logger_method = logger.error
371+
elif status >= 500:
372+
logger_method = logger.critical
373+
logger_method(message)
350374

351375
# Profiling end
352376
if self._profiling_per_request:
@@ -459,6 +483,9 @@ def response(status: int, headers: types.WSGIResponseHeaders,
459483
logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching (may cause authentication issues using internal WebUI)", base_prefix, path)
460484
else:
461485
logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
486+
if not app_base._check_path_format(self._storage, path, self._validate_path_value):
487+
logger.error("request contains invalid path: %r (not compliant to %r)", path, self._validate_path_value)
488+
return response(*httputils.BAD_REQUEST)
462489

463490
# Get function corresponding to method
464491
function = getattr(self, "do_%s" % request_method, None)
@@ -489,7 +516,11 @@ def response(status: int, headers: types.WSGIResponseHeaders,
489516
self.configuration, environ, base64.b64decode(
490517
authorization.encode("ascii"))).split(":", 1)
491518

492-
(user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
519+
if login and not app_base._check_user_format(self._storage, login, self._validate_user_value):
520+
info = "not compliant to %r" % self._validate_user_value
521+
user = ""
522+
else:
523+
(user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
493524
if self.configuration.get("auth", "type") == "ldap":
494525
try:
495526
logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups))
@@ -600,9 +631,9 @@ def response(status: int, headers: types.WSGIResponseHeaders,
600631

601632
if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED:
602633
if path.startswith("/.token"):
603-
logger.info("Access to %r denied", path)
634+
logger.notice("Access to %r denied", path)
604635
else:
605-
logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user")
636+
logger.notice("Access to %r denied for %s", path, repr(user) if user else "anonymous user")
606637
else:
607638
status, headers, answer, xml_request = httputils.NOT_ALLOWED
608639

radicale/app/base.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
import io
1919
import logging
20+
import re
2021
import sys
22+
import unicodedata
2123
import xml.etree.ElementTree as ET
2224
from typing import Optional, Union
2325

@@ -30,6 +32,82 @@
3032
import defusedxml.ElementTree as DefusedET # isort:skip
3133
sys.modules["xml.etree"].ElementTree = ET # type:ignore[attr-defined]
3234

35+
USER_PATTERN_STRICT: str = "a-zA-Z0-9@\\.\\-_"
36+
PATH_PATTERN_STRICT: str = USER_PATTERN_STRICT + "\\/~" # / as separator
37+
38+
USER_PATTERN_STRICT_RE: str = "^[" + USER_PATTERN_STRICT + "]+$"
39+
PATH_PATTERN_STRICT_RE: str = "^[" + PATH_PATTERN_STRICT + "]+$"
40+
41+
USER_BLACKLIST_MINIMAL: list = [":", "'", '"', '*', '?']
42+
PATH_BLACKLIST_MINIMAL: list = USER_BLACKLIST_MINIMAL
43+
44+
USER_WHITELIST_UNICODE: list = ["-", ".", "@", "_"] # from USER_PATTERN_STRICT
45+
PATH_WHITELIST_UNICODE: list = ["-", ".", "@", "_", "/", "~"] # from PATH_PATTERN_STRICT
46+
47+
48+
def _check_format(self: storage.BaseStorage,
49+
string: str,
50+
blacklist_minimal: list[str],
51+
whitelist_unicode: list[str],
52+
validation_type: str,
53+
) -> bool:
54+
check_minimal = (validation_type == "minimal")
55+
check_unicode_letter = (validation_type == "unicode-letter")
56+
check_no_unicode = (validation_type == "no-unicode")
57+
logger.trace("_check_format investigate %r (validation_type=%r check_minimal=%s check_unicode_letter=%s check_no_unicode=%s)", string, validation_type, check_minimal, check_unicode_letter, check_no_unicode)
58+
if not self._supports_trailing_whitespace and string.endswith(' '):
59+
return False
60+
for c in string:
61+
if c <= chr(31) or (c >= chr(127) and c <= chr(159)):
62+
# ASCII: control char
63+
return False
64+
if unicodedata.category(c)[0] == "C":
65+
# https://unicodeplus.com/category
66+
# Unicode: control
67+
return False
68+
if check_minimal or not self._supports_problematic_chars:
69+
if c in blacklist_minimal:
70+
logger.trace("_check_format found %r", c)
71+
return False
72+
if check_unicode_letter:
73+
if c not in whitelist_unicode:
74+
if unicodedata.category(c)[0] != "L":
75+
return False
76+
if check_no_unicode:
77+
if ord(c) > 255:
78+
return False
79+
return True
80+
81+
82+
def _check_user_format(self: storage.BaseStorage,
83+
user: str,
84+
validation_type: str
85+
) -> bool:
86+
if validation_type == "strict":
87+
return (re.search(USER_PATTERN_STRICT_RE, user) is not None)
88+
else:
89+
return _check_format(self,
90+
user,
91+
USER_BLACKLIST_MINIMAL,
92+
USER_WHITELIST_UNICODE,
93+
validation_type,
94+
)
95+
96+
97+
def _check_path_format(self: storage.BaseStorage,
98+
path: str,
99+
validation_type: str
100+
) -> bool:
101+
if validation_type == "strict":
102+
return (re.search(PATH_PATTERN_STRICT_RE, path) is not None)
103+
else:
104+
return _check_format(self,
105+
path,
106+
PATH_BLACKLIST_MINIMAL,
107+
PATH_WHITELIST_UNICODE,
108+
validation_type,
109+
)
110+
33111

34112
class ApplicationBase:
35113

@@ -44,6 +122,8 @@ class ApplicationBase:
44122
_permit_delete_collection: bool
45123
_permit_overwrite_collection: bool
46124
_strict_preconditions: bool
125+
_validate_user_value: str
126+
_validate_path_format: str
47127
_hook: hook.BaseHook
48128

49129
def __init__(self, configuration: config.Configuration) -> None:
@@ -58,6 +138,8 @@ def __init__(self, configuration: config.Configuration) -> None:
58138
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
59139
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
60140
self._limit_content = configuration.get("logging", "limit_content")
141+
self._validate_user_value = configuration.get("server", "validate_user_value")
142+
self._validate_path_value = configuration.get("server", "validate_path_value")
61143
self._hook = hook.load(configuration)
62144

63145
def _read_xml_request_body(self, environ: types.WSGIEnviron

radicale/app/move.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from urllib.parse import unquote, urlparse
2626

2727
from radicale import httputils, pathutils, storage, types
28+
from radicale.app import base as app_base
2829
from radicale.app.base import Access, ApplicationBase
2930
from radicale.log import logger
3031

@@ -82,6 +83,9 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
8283
if not access.check("w"):
8384
return httputils.NOT_ALLOWED
8485
to_path = pathutils.sanitize_path(to_url.path)
86+
if not app_base._check_path_format(self._storage, to_path, self._validate_path_value):
87+
logger.warning("request contains invalid path: %r (not compliant to %r)", to_path, self._validate_path_value)
88+
return httputils.BAD_REQUEST
8589
if not (to_path + "/").startswith(base_prefix + "/"):
8690
logger.warning("Destination %r from MOVE request on %r doesn't "
8791
"start with base prefix", to_path, path)

0 commit comments

Comments
 (0)