2021-12-08 21:45:42 +01:00
# This file is part of Radicale - CalDAV and CardDAV server
2018-08-28 16:19:36 +02:00
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
2019-06-17 04:13:25 +02:00
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
2025-01-01 15:47:22 +01:00
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
2018-08-28 16:19:36 +02:00
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale WSGI application .
2020-01-12 23:32:28 +01:00
Can be used with an external WSGI server ( see ` ` radicale . application ( ) ` ` ) or
the built - in server ( see ` ` radicale . server ` ` module ) .
2018-08-28 16:19:36 +02:00
"""
import base64
import datetime
import pprint
import random
import time
import zlib
from http import client
2021-07-26 20:56:46 +02:00
from typing import Iterable , List , Mapping , Tuple , Union
2018-08-28 16:19:36 +02:00
2021-07-26 20:56:46 +02:00
from radicale import config , httputils , log , pathutils , types
from radicale . app . base import ApplicationBase
from radicale . app . delete import ApplicationPartDelete
from radicale . app . get import ApplicationPartGet
from radicale . app . head import ApplicationPartHead
from radicale . app . mkcalendar import ApplicationPartMkcalendar
from radicale . app . mkcol import ApplicationPartMkcol
from radicale . app . move import ApplicationPartMove
from radicale . app . options import ApplicationPartOptions
from radicale . app . post import ApplicationPartPost
from radicale . app . propfind import ApplicationPartPropfind
from radicale . app . proppatch import ApplicationPartProppatch
from radicale . app . put import ApplicationPartPut
from radicale . app . report import ApplicationPartReport
2018-08-28 16:19:36 +02:00
from radicale . log import logger
2021-07-26 20:56:46 +02:00
# Combination of types.WSGIStartResponse and WSGI application return value
_IntermediateResponse = Tuple [ str , List [ Tuple [ str , str ] ] , Iterable [ bytes ] ]
2018-08-28 16:19:36 +02:00
2021-07-26 20:56:46 +02:00
class Application ( ApplicationPartDelete , ApplicationPartHead ,
ApplicationPartGet , ApplicationPartMkcalendar ,
ApplicationPartMkcol , ApplicationPartMove ,
ApplicationPartOptions , ApplicationPartPropfind ,
ApplicationPartProppatch , ApplicationPartPost ,
ApplicationPartPut , ApplicationPartReport , ApplicationBase ) :
2020-01-12 23:32:28 +01:00
""" WSGI application. """
2018-08-28 16:19:36 +02:00
2021-07-26 20:56:46 +02:00
_mask_passwords : bool
_auth_delay : float
_internal_server : bool
_max_content_length : int
_auth_realm : str
2025-08-22 08:46:25 +02:00
_auth_type : str
_web_type : str
2025-03-02 09:05:12 +01:00
_script_name : str
2021-07-26 20:56:46 +02:00
_extra_headers : Mapping [ str , str ]
2024-03-09 06:43:39 +01:00
_permit_delete_collection : bool
2024-09-29 18:15:42 +02:00
_permit_overwrite_collection : bool
2021-07-26 20:56:46 +02:00
def __init__ ( self , configuration : config . Configuration ) - > None :
2020-01-13 15:51:10 +01:00
""" Initialize Application.
2020-01-12 23:32:28 +01:00
` ` configuration ` ` see ` ` radicale . config ` ` module .
2020-01-13 15:51:10 +01:00
The ` ` configuration ` ` must not change during the lifetime of
this object , it is kept as an internal reference .
2020-01-12 23:32:28 +01:00
"""
2021-07-26 20:56:46 +02:00
super ( ) . __init__ ( configuration )
self . _mask_passwords = configuration . get ( " logging " , " mask_passwords " )
2024-05-29 06:07:36 +02:00
self . _bad_put_request_content = configuration . get ( " logging " , " bad_put_request_content " )
2024-06-11 13:26:21 +02:00
self . _request_header_on_debug = configuration . get ( " logging " , " request_header_on_debug " )
self . _response_content_on_debug = configuration . get ( " logging " , " response_content_on_debug " )
2021-07-26 20:56:46 +02:00
self . _auth_delay = configuration . get ( " auth " , " delay " )
2025-08-22 08:46:25 +02:00
self . _auth_type = configuration . get ( " auth " , " type " )
self . _web_type = configuration . get ( " web " , " type " )
2021-07-26 20:56:46 +02:00
self . _internal_server = configuration . get ( " server " , " _internal_server " )
2025-03-02 09:05:12 +01:00
self . _script_name = configuration . get ( " server " , " script_name " )
if self . _script_name :
if self . _script_name [ 0 ] != " / " :
logger . error ( " server.script_name must start with ' / ' : %r " , self . _script_name )
raise RuntimeError ( " server.script_name option has to start with ' / ' " )
else :
if self . _script_name . endswith ( " / " ) :
logger . error ( " server.script_name must not end with ' / ' : %r " , self . _script_name )
raise RuntimeError ( " server.script_name option must not end with ' / ' " )
else :
logger . info ( " Provided script name to strip from URI if called by reverse proxy: %r " , self . _script_name )
else :
logger . info ( " Default script name to strip from URI if called by reverse proxy is taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME " )
2021-07-26 20:56:46 +02:00
self . _max_content_length = configuration . get (
" server " , " max_content_length " )
self . _auth_realm = configuration . get ( " auth " , " realm " )
2024-03-09 06:43:39 +01:00
self . _permit_delete_collection = configuration . get ( " rights " , " permit_delete_collection " )
logger . info ( " permit delete of collection: %s " , self . _permit_delete_collection )
2024-09-29 18:15:42 +02:00
self . _permit_overwrite_collection = configuration . get ( " rights " , " permit_overwrite_collection " )
logger . info ( " permit overwrite of collection: %s " , self . _permit_overwrite_collection )
2021-07-26 20:56:46 +02:00
self . _extra_headers = dict ( )
for key in self . configuration . options ( " headers " ) :
self . _extra_headers [ key ] = configuration . get ( " headers " , key )
def _scrub_headers ( self , environ : types . WSGIEnviron ) - > types . WSGIEnviron :
""" Mask passwords and cookies. """
headers = dict ( environ )
if ( self . _mask_passwords and
headers . get ( " HTTP_AUTHORIZATION " , " " ) . startswith ( " Basic " ) ) :
headers [ " HTTP_AUTHORIZATION " ] = " Basic **masked** "
if headers . get ( " HTTP_COOKIE " ) :
headers [ " HTTP_COOKIE " ] = " **masked** "
return headers
def __call__ ( self , environ : types . WSGIEnviron , start_response :
types . WSGIStartResponse ) - > Iterable [ bytes ] :
2018-08-28 16:19:36 +02:00
with log . register_stream ( environ [ " wsgi.errors " ] ) :
try :
2021-07-26 20:56:46 +02:00
status_text , headers , answers = self . _handle_request ( environ )
2018-08-28 16:19:36 +02:00
except Exception as e :
logger . error ( " An exception occurred during %s request on %r : "
2021-07-26 20:56:46 +02:00
" %s " , environ . get ( " REQUEST_METHOD " , " unknown " ) ,
environ . get ( " PATH_INFO " , " " ) , e , exc_info = True )
# Make minimal response
status , raw_headers , raw_answer = (
httputils . INTERNAL_SERVER_ERROR )
assert isinstance ( raw_answer , str )
answer = raw_answer . encode ( " ascii " )
status_text = " %d %s " % (
status , client . responses . get ( status , " Unknown " ) )
headers = [ * raw_headers , ( " Content-Length " , str ( len ( answer ) ) ) ]
2018-08-28 16:19:36 +02:00
answers = [ answer ]
2021-07-26 20:56:46 +02:00
start_response ( status_text , headers )
2022-01-19 19:58:05 +01:00
if environ . get ( " REQUEST_METHOD " ) == " HEAD " :
return [ ]
2018-08-28 16:19:36 +02:00
return answers
2021-07-26 20:56:46 +02:00
def _handle_request ( self , environ : types . WSGIEnviron
) - > _IntermediateResponse :
2022-01-15 22:32:38 +01:00
time_begin = datetime . datetime . now ( )
request_method = environ [ " REQUEST_METHOD " ] . upper ( )
2022-01-18 18:20:14 +01:00
unsafe_path = environ . get ( " PATH_INFO " , " " )
2025-03-08 16:50:35 +01:00
https = environ . get ( " HTTPS " , " " )
2022-01-15 22:32:38 +01:00
2018-08-28 16:19:36 +02:00
""" Manage a request. """
2021-07-26 20:56:46 +02:00
def response ( status : int , headers : types . WSGIResponseHeaders ,
answer : Union [ None , str , bytes ] ) - > _IntermediateResponse :
""" Helper to create response from internal types.WSGIResponse """
2018-08-28 16:19:36 +02:00
headers = dict ( headers )
# Set content length
2021-07-26 20:56:46 +02:00
answers = [ ]
if answer is not None :
if isinstance ( answer , str ) :
2024-06-11 13:26:21 +02:00
if self . _response_content_on_debug :
logger . debug ( " Response content: \n %s " , answer )
2024-06-18 08:24:25 +02:00
else :
2024-08-28 07:48:45 +02:00
logger . debug ( " Response content: suppressed by config/option [logging] response_content_on_debug " )
2020-01-14 06:19:11 +01:00
headers [ " Content-Type " ] + = " ; charset= %s " % self . _encoding
answer = answer . encode ( self . _encoding )
2018-08-28 16:19:36 +02:00
accept_encoding = [
encoding . strip ( ) for encoding in
environ . get ( " HTTP_ACCEPT_ENCODING " , " " ) . split ( " , " )
if encoding . strip ( ) ]
if " gzip " in accept_encoding :
zcomp = zlib . compressobj ( wbits = 16 + zlib . MAX_WBITS )
answer = zcomp . compress ( answer ) + zcomp . flush ( )
headers [ " Content-Encoding " ] = " gzip "
headers [ " Content-Length " ] = str ( len ( answer ) )
2022-01-19 19:58:05 +01:00
answers . append ( answer )
2018-08-28 16:19:36 +02:00
# Add extra headers set in configuration
2021-07-26 20:56:46 +02:00
headers . update ( self . _extra_headers )
2018-08-28 16:19:36 +02:00
# Start response
time_end = datetime . datetime . now ( )
2021-07-26 20:56:46 +02:00
status_text = " %d %s " % (
2018-08-28 16:19:36 +02:00
status , client . responses . get ( status , " Unknown " ) )
2022-01-18 18:20:14 +01:00
logger . info ( " %s response status for %r %s in %.3f seconds: %s " ,
request_method , unsafe_path , depthinfo ,
( time_end - time_begin ) . total_seconds ( ) , status_text )
2018-08-28 16:19:36 +02:00
# Return response content
2021-07-26 20:56:46 +02:00
return status_text , list ( headers . items ( ) ) , answers
2018-08-28 16:19:36 +02:00
2025-03-02 09:05:41 +01:00
reverse_proxy = False
2018-08-28 16:19:36 +02:00
remote_host = " unknown "
if environ . get ( " REMOTE_HOST " ) :
remote_host = repr ( environ [ " REMOTE_HOST " ] )
elif environ . get ( " REMOTE_ADDR " ) :
remote_host = environ [ " REMOTE_ADDR " ]
if environ . get ( " HTTP_X_FORWARDED_FOR " ) :
2025-03-02 09:05:41 +01:00
reverse_proxy = True
2020-09-26 22:08:23 +02:00
remote_host = " %s (forwarded for %r ) " % (
remote_host , environ [ " HTTP_X_FORWARDED_FOR " ] )
2025-03-02 09:05:41 +01:00
if environ . get ( " HTTP_X_FORWARDED_HOST " ) or environ . get ( " HTTP_X_FORWARDED_PROTO " ) or environ . get ( " HTTP_X_FORWARDED_SERVER " ) :
reverse_proxy = True
2018-08-28 16:19:36 +02:00
remote_useragent = " "
if environ . get ( " HTTP_USER_AGENT " ) :
remote_useragent = " using %r " % environ [ " HTTP_USER_AGENT " ]
depthinfo = " "
if environ . get ( " HTTP_DEPTH " ) :
depthinfo = " with depth %r " % environ [ " HTTP_DEPTH " ]
2025-03-08 16:50:35 +01:00
if https :
https_info = " " + environ . get ( " SSL_PROTOCOL " , " " ) + " " + environ . get ( " SSL_CIPHER " , " " )
else :
https_info = " "
logger . info ( " %s request for %r %s received from %s %s %s " ,
2022-01-15 22:32:37 +01:00
request_method , unsafe_path , depthinfo ,
2025-03-08 16:50:35 +01:00
remote_host , remote_useragent , https_info )
2024-06-11 13:26:21 +02:00
if self . _request_header_on_debug :
2024-06-18 08:24:25 +02:00
logger . debug ( " Request header: \n %s " ,
2024-06-11 13:26:21 +02:00
pprint . pformat ( self . _scrub_headers ( environ ) ) )
2024-06-18 08:24:25 +02:00
else :
2024-08-28 07:48:45 +02:00
logger . debug ( " Request header: suppressed by config/option [logging] request_header_on_debug " )
2018-08-28 16:19:36 +02:00
2022-01-15 22:32:37 +01:00
# SCRIPT_NAME is already removed from PATH_INFO, according to the
# WSGI specification.
# Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header
2025-03-02 09:06:30 +01:00
if self . _script_name and ( reverse_proxy is True ) :
base_prefix_src = " config "
base_prefix = self . _script_name
else :
base_prefix_src = ( " HTTP_X_SCRIPT_NAME " if " HTTP_X_SCRIPT_NAME " in
environ else " SCRIPT_NAME " )
base_prefix = environ . get ( base_prefix_src , " " )
if base_prefix and base_prefix [ 0 ] != " / " :
logger . error ( " Base prefix (from %s ) must start with ' / ' : %r " ,
base_prefix_src , base_prefix )
if base_prefix_src == " HTTP_X_SCRIPT_NAME " :
return response ( * httputils . BAD_REQUEST )
return response ( * httputils . INTERNAL_SERVER_ERROR )
if base_prefix . endswith ( " / " ) :
logger . warning ( " Base prefix (from %s ) must not end with ' / ' : %r " ,
base_prefix_src , base_prefix )
base_prefix = base_prefix . rstrip ( " / " )
if base_prefix :
logger . debug ( " Base prefix (from %s ): %r " , base_prefix_src , base_prefix )
2018-08-28 16:19:36 +02:00
# Sanitize request URI (a WSGI server indicates with an empty path,
# that the URL targets the application root without a trailing slash)
2022-01-15 22:32:37 +01:00
path = pathutils . sanitize_path ( unsafe_path )
2018-08-28 16:19:36 +02:00
logger . debug ( " Sanitized path: %r " , path )
2025-03-02 09:06:30 +01:00
if ( reverse_proxy is True ) and ( len ( base_prefix ) > 0 ) :
if path . startswith ( base_prefix ) :
path_new = path . removeprefix ( base_prefix )
logger . debug ( " Called by reverse proxy, remove base prefix %r from path: %r => %r " , base_prefix , path , path_new )
path = path_new
else :
2025-08-22 08:46:25 +02:00
if self . _auth_type in [ ' remote_user ' , ' http_x_remote_user ' ] and self . _web_type == ' internal ' :
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 )
else :
logger . debug ( " Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching " , base_prefix , path )
2018-08-28 16:19:36 +02:00
# Get function corresponding to method
2022-01-15 22:32:37 +01:00
function = getattr ( self , " do_ %s " % request_method , None )
2020-09-12 20:23:45 +02:00
if not function :
return response ( * httputils . METHOD_NOT_ALLOWED )
2018-08-28 16:19:36 +02:00
2022-01-22 18:19:41 +01:00
# Redirect all "…/.well-known/{caldav,carddav}" paths to "/".
# This shouldn't be necessary but some clients like TbSync require it.
# Status must be MOVED PERMANENTLY using FOUND causes problems
if ( path . rstrip ( " / " ) . endswith ( " /.well-known/caldav " ) or
path . rstrip ( " / " ) . endswith ( " /.well-known/carddav " ) ) :
return response ( * httputils . redirect (
base_prefix + " / " , client . MOVED_PERMANENTLY ) )
2024-07-24 11:22:49 +02:00
# Return NOT FOUND for all other paths containing ".well-known"
2022-01-22 18:19:41 +01:00
if path . endswith ( " /.well-known " ) or " /.well-known/ " in path :
2018-08-28 16:19:36 +02:00
return response ( * httputils . NOT_FOUND )
# Ask authentication backend to check rights
login = password = " "
2020-01-14 06:19:11 +01:00
external_login = self . _auth . get_external_login ( environ )
2018-08-28 16:19:36 +02:00
authorization = environ . get ( " HTTP_AUTHORIZATION " , " " )
if external_login :
login , password = external_login
login , password = login or " " , password or " "
elif authorization . startswith ( " Basic " ) :
authorization = authorization [ len ( " Basic " ) : ] . strip ( )
2020-09-14 21:19:48 +02:00
login , password = httputils . decode_request (
self . configuration , environ , base64 . b64decode (
authorization . encode ( " ascii " ) ) ) . split ( " : " , 1 )
2018-08-28 16:19:36 +02:00
2025-01-01 16:30:34 +01:00
( user , info ) = self . _auth . login ( login , password ) or ( " " , " " ) if login else ( " " , " " )
2024-08-26 11:21:53 +02:00
if self . configuration . get ( " auth " , " type " ) == " ldap " :
try :
2025-07-21 19:38:15 +02:00
logger . debug ( " Groups received from LDAP: %r " , " , " . join ( self . _auth . _ldap_groups ) )
2024-08-26 11:21:53 +02:00
self . _rights . _user_groups = self . _auth . _ldap_groups
except AttributeError :
pass
2018-08-28 16:19:36 +02:00
if user and login == user :
2025-01-01 16:30:34 +01:00
logger . info ( " Successful login: %r ( %s ) " , user , info )
2018-08-28 16:19:36 +02:00
elif user :
2025-01-01 16:30:34 +01:00
logger . info ( " Successful login: %r -> %r ( %s ) " , login , user , info )
2018-08-28 16:19:36 +02:00
elif login :
2025-01-01 16:30:34 +01:00
logger . warning ( " Failed login attempt from %s : %r ( %s ) " ,
remote_host , login , info )
2018-08-28 16:19:36 +02:00
# Random delay to avoid timing oracles and bruteforce attacks
2021-07-26 20:56:46 +02:00
if self . _auth_delay > 0 :
random_delay = self . _auth_delay * ( 0.5 + random . random ( ) )
2025-01-03 07:11:51 +01:00
logger . debug ( " Failed login, sleeping random: %.3f sec " , random_delay )
2018-08-28 16:19:36 +02:00
time . sleep ( random_delay )
if user and not pathutils . is_safe_path_component ( user ) :
# Prevent usernames like "user/calendar.ics"
logger . info ( " Refused unsafe username: %r " , user )
user = " "
# Create principal collection
if user :
principal_path = " / %s / " % user
2020-04-22 19:20:07 +02:00
with self . _storage . acquire_lock ( " r " , user ) :
2021-07-26 20:56:46 +02:00
principal = next ( iter ( self . _storage . discover (
principal_path , depth = " 1 " ) ) , None )
2020-04-22 19:20:07 +02:00
if not principal :
if " W " in self . _rights . authorization ( user , principal_path ) :
2020-01-14 06:19:11 +01:00
with self . _storage . acquire_lock ( " w " , user ) :
2018-08-28 16:19:36 +02:00
try :
2025-07-14 00:16:19 -06:00
new_coll , _ , _ = self . _storage . create_collection ( principal_path )
2024-04-22 12:23:24 +03:00
if new_coll :
jsn_coll = self . configuration . get ( " storage " , " predefined_collections " )
for ( name_coll , props ) in jsn_coll . items ( ) :
try :
2024-05-03 23:07:04 +03:00
self . _storage . create_collection ( principal_path + name_coll , props = props )
2024-04-22 12:23:24 +03:00
except ValueError as e :
logger . warning ( " Failed to create predefined collection %r : %s " , name_coll , e )
2018-08-28 16:19:36 +02:00
except ValueError as e :
logger . warning ( " Failed to create principal "
" collection %r : %s " , user , e )
user = " "
2020-04-22 19:20:07 +02:00
else :
logger . warning ( " Access to principal path %r denied by "
" rights backend " , principal_path )
2018-08-28 16:19:36 +02:00
2021-07-26 20:56:46 +02:00
if self . _internal_server :
2018-08-28 16:19:36 +02:00
# Verify content length
content_length = int ( environ . get ( " CONTENT_LENGTH " ) or 0 )
if content_length :
2021-07-26 20:56:46 +02:00
if ( self . _max_content_length > 0 and
content_length > self . _max_content_length ) :
2018-08-28 16:19:36 +02:00
logger . info ( " Request body too large: %d " , content_length )
return response ( * httputils . REQUEST_ENTITY_TOO_LARGE )
if not login or user :
status , headers , answer = function (
environ , base_prefix , path , user )
if ( status , headers , answer ) == httputils . NOT_ALLOWED :
logger . info ( " Access to %r denied for %s " , path ,
repr ( user ) if user else " anonymous user " )
else :
status , headers , answer = httputils . NOT_ALLOWED
if ( ( status , headers , answer ) == httputils . NOT_ALLOWED and not user and
not external_login ) :
# Unknown or unauthorized user
logger . debug ( " Asking client for authentication " )
status = client . UNAUTHORIZED
headers = dict ( headers )
headers . update ( {
" WWW-Authenticate " :
2021-07-26 20:56:46 +02:00
" Basic realm= \" %s \" " % self . _auth_realm } )
2018-08-28 16:19:36 +02:00
return response ( status , headers , answer )