From ebb359c5cc347e9609865bcb40f412d9ee191646 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 16:22:02 +0200 Subject: [PATCH] sharing/bday/age_max: input validation --- radicale/config.py | 2 +- radicale/sharing/__init__.py | 29 +++++++++++++----- radicale/tests/test_sharing.py | 55 +++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index 24899190..b92c6bb4 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -609,7 +609,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("conversion_bday_age_max", { "value": "99", "help": "conversion bday age max", - "type": positive_int}), + "type": sharing.check_bday_max_age}), ])), ("hook", OrderedDict([ ("type", { diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index faa448b3..1b5020ca 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -24,7 +24,7 @@ import uuid from csv import DictWriter from datetime import datetime from http import client -from typing import Sequence, Union +from typing import Any, Sequence, Union from urllib.parse import parse_qs from radicale import (config, httputils, pathutils, rights, storage, types, @@ -123,14 +123,24 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") +SHARING_BDAY_AGE_MAX: int = 199 # maximum age to prevent unexpected DoS by config + + +def check_bday_max_age(data: Any) -> int: + value = int(data) + if value < 0: + raise ValueError("value is negative: %d" % value) + if value > SHARING_BDAY_AGE_MAX: + raise ValueError("value exceeds maximum (%d): %d" % (SHARING_BDAY_AGE_MAX, value)) + return value + + ACTIONS_WHITELIST: dict = { - 'template': { + 'config': { 'conversion_bday_summary_template': str, 'conversion_bday_description_template': str, 'conversion_bday_alarm_trigger_template': str, - }, - 'limit': { - 'conversion_bday_age_max': "positive_int", + 'conversion_bday_age_max': check_bday_max_age, }, } @@ -862,9 +872,12 @@ class BaseSharing: if level1 in ACTIONS_WHITELIST: for level2 in request_data['Actions'][level1]: if level2 in ACTIONS_WHITELIST[level1]: - if ACTIONS_WHITELIST[level1][level2] == "positive_int": - if int(request_data['Actions'][level1][level2]) < 0: - hint = "'" + level1 + "': {'" + level2 + "'} is negative" + logger.trace(api_info + ": Actions validation: type='%r'", type(ACTIONS_WHITELIST[level1][level2])) + if callable(ACTIONS_WHITELIST[level1][level2]): + try: + value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2]) + except ValueError: + hint = "'" + level1 + "': {'" + level2 + "'} is out-of-range" valid = False break pass diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 7c8ce93c..3f468e56 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5131,6 +5131,26 @@ permissions: RrWw""") "conversion_bday_description_template": "BDAY={year}-{month}-{day}", }}) + logging.info("\n*** configuration test: conversion_bday_age_max < 0") + try: + self.configure({"sharing": { + "conversion_bday_age_max": -1, + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_age_max > MAX") + try: + self.configure({"sharing": { + "conversion_bday_age_max": 200, + }}) + except RuntimeError: + pass + else: + raise + # verify content as user logging.info("\n*** GET collection user format:default -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") @@ -5217,11 +5237,44 @@ permissions: RrWw""") json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - json_dict['Actions'] = {"limit": { + json_dict['Actions'] = {"config": { "conversion_bday_age_max": -1, }} _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + # update template with invalid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max exceeds MAX") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX + 1), + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + # update template with valid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": 0, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + # update template with valid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max < MAX") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": sharing.SHARING_BDAY_AGE_MAX, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** update map(bday) user/owner:r -> unsupported level 1") json_dict = {} json_dict['User'] = "user"