Download Install Tutorial Docs FAQ Tools WikiLicense Team IRC Planet Involvement Shop Book

root/branches/cherrypy-2.x/cherrypy/filters/sessionfilter.py

Revision 1775 (checked in by fumanchu, 1 year ago)

2.x fix for #744 (Malicious cookies may allow access to files outside the session directory).

  • Property svn:eol-style set to native
Line 
1 """ Session implementation for CherryPy.
2 We use cherrypy.request to store some convenient variables as
3 well as data about the session for the current request. Instead of
4 polluting cherrypy.request we use a dummy object called
5 cherrypy.request._session (sess) to store these variables.
6
7 Variables used to store config options:
8     - sess.session_timeout: timeout delay for the session
9     - sess.session_locking: mechanism used to lock the session ('implicit' or 'explicit')
10
11 Variables used to store temporary variables:
12     - sess.session_storage (instance of the class implementing the backend)
13
14
15 Variables used to store the session for the current request:
16     - sess.session_data: dictionary containing the actual session data
17     - sess.session_id: current session ID
18     - sess.expiration_time: date/time when the current session will expire
19
20 Global variables (RAM backend only):
21     - cherrypy._session_lock_dict: dictionary containing the locks for all session_id
22     - cherrypy._session_data_holder: dictionary containing the data for all sessions
23
24 """
25
26 import datetime
27 import os
28 try:
29     import cPickle as pickle
30 except ImportError:
31     import pickle
32 import random
33 import sha
34 import time
35 import thread
36 import threading
37 import types
38
39 import cherrypy
40 import basefilter
41 from cherrypy.lib import httptools
42
43
44 class EmptyClass:
45     """ An empty class """
46     pass
47
48
49 class SessionDeadlockError(Exception):
50     """ The session could not acquire a lock after a certain time """
51     pass
52
53
54 class SessionNotEnabledError(Exception):
55     """ User forgot to set session_filter.on to True """
56     pass
57
58 class SessionStoragePathNotConfiguredError(Exception):
59     """
60     User set storage_type to file but forgot to set the storage_path
61     """
62     pass
63
64
65 class SessionFilter(basefilter.BaseFilter):
66
67     def on_start_resource(self):
68         cherrypy.request._session = EmptyClass()
69    
70     def before_request_body(self):
71         conf = cherrypy.config.get
72        
73         sess = cherrypy.request._session
74         if not conf('session_filter.on', False):
75             sess.session_storage = None
76             return
77
78         sess.locked = False # Not locked by default
79         sess.to_be_loaded = True
80        
81         # Read config options
82         sess.session_timeout = conf('session_filter.timeout', 60)
83         sess.session_locking = conf('session_filter.locking', 'explicit')
84         sess.on_create_session = conf('session_filter.on_create_session',
85                 lambda data: None)
86         sess.on_renew_session = conf('session_filter.on_renew_session',
87                 lambda data: None)
88         sess.on_delete_session = conf('session_filter.on_delete_session',
89                 lambda data: None)
90         sess.generate_session_id = conf('session_filter.generate_session_id',
91                 generate_session_id)
92        
93         clean_up_delay = conf('session_filter.clean_up_delay', 5)
94         clean_up_delay = datetime.timedelta(seconds = clean_up_delay * 60)
95
96         cookie_name = conf('session_filter.cookie_name', 'session_id')
97         cookie_domain = conf('session_filter.cookie_domain', None)
98         cookie_secure = conf('session_filter.cookie_secure', False)
99         cookie_path = conf('session_filter.cookie_path', None)
100
101         if cookie_path is None:
102             cookie_path_header = conf('session_filter.cookie_path_from_header', None)
103             if cookie_path_header is not None:
104                 cookie_path = cherrypy.request.headerMap.get(cookie_path_header, None)
105             if cookie_path is None:
106                 cookie_path = '/'
107
108         sess.deadlock_timeout = conf('session_filter.deadlock_timeout', 30)
109        
110         storage = conf('session_filter.storage_type', 'Ram')
111         storage = storage[0].upper() + storage[1:]
112        
113         # People can set their own custom class
114         #   through session_filter.storage_class
115         sess.session_storage = conf('session_filter.storage_class', None)
116         if sess.session_storage is None:
117             sess.session_storage = globals()[storage + 'Storage']()
118         else:
119             sess.session_storage = sess.session_storage()
120        
121         now = datetime.datetime.now()
122         # Check if we need to clean up old sessions
123         if cherrypy._session_last_clean_up_time + clean_up_delay < now:
124             cherrypy._session_last_clean_up_time = now
125             # Run clean_up function in other thread to avoid blocking
126             #   this request
127             thread.start_new_thread(sess.session_storage.clean_up, (sess,))
128        
129         # Check if request came with a session ID
130         if cookie_name in cherrypy.request.simple_cookie:
131             # It did: we mark the data as needing to be loaded
132             sess.session_id = cherrypy.request.simple_cookie[cookie_name].value
133            
134             # If using implicit locking, acquire lock
135             if sess.session_locking == 'implicit':
136                 sess.session_data = {'_id': sess.session_id}
137                 sess.session_storage.acquire_lock()
138            
139             sess.to_be_loaded = True
140
141         else:
142             # No session_id yet
143             id = None
144             while id is None:
145                 id = sess.generate_session_id()
146                 # Assert that the generated id is not already stored.
147                 if sess.session_storage.load(id) is not None:
148                     id = None
149             sess.session_id = id
150            
151             sess.session_data = {'_id': sess.session_id}
152             sess.on_create_session(sess.session_data)
153         # Set response cookie
154         cookie = cherrypy.response.simple_cookie
155         cookie[cookie_name] = sess.session_id
156         cookie[cookie_name]['path'] = cookie_path
157         # We'd like to use the "max-age" param as
158         #   http://www.faqs.org/rfcs/rfc2109.html indicates but IE doesn't
159         #   save it to disk and the session is lost if people close
160         #   the browser
161         #   So we have to use the old "expires" ... sigh ...
162         #cookie[cookie_name]['max-age'] = sess.session_timeout * 60
163         if sess.session_timeout:
164             gmt_expiration_time = time.gmtime(time.time() +
165                                               (sess.session_timeout * 60))
166             cookie[cookie_name]['expires'] = httptools.HTTPDate(gmt_expiration_time)
167         if cookie_domain is not None:
168             cookie[cookie_name]['domain'] = cookie_domain
169         if cookie_secure is True:
170             cookie[cookie_name]['secure'] = 1
171    
172     def before_finalize(self):
173         def saveData(body, sess):
174             # If the body is a generator, we have to save the data
175             #   *after* the generator has been consumed
176             if isinstance(body, types.GeneratorType):
177                 for line in body:
178                     yield line
179            
180             # Save session data
181             if sess.to_be_loaded is False:
182                 t = datetime.timedelta(seconds = sess.session_timeout * 60)
183                 expiration_time = datetime.datetime.now() + t
184                 sess.session_storage.save(sess.session_id,
185                         sess.session_data, expiration_time)
186             else:
187                 # If session data has never been loaded then it's never been
188                 #   accesses: not need to delete it
189                 pass
190             if sess.locked:
191                 # Always release the lock if the user didn't release it
192                 sess.session_storage.release_lock()
193            
194             # If the body is not a generator, we save the data
195             #   before the body is returned
196             if not isinstance(body, types.GeneratorType):
197                 for line in body:
198                     yield line
199        
200         sess = cherrypy.request._session
201         if not getattr(sess, 'session_storage', None):
202             # Sessions are not enabled: do nothing
203             return
204        
205         # Make a wrapper around the body in order to save the session
206         #   either before or after the body is returned
207         cherrypy.response.body = saveData(cherrypy.response.body, sess)
208    
209     def on_end_request(self):
210         sess = cherrypy.request._session
211         if not getattr(sess, 'session_storage', None):
212             # Sessions are not enabled: do nothing
213             return
214         if getattr(sess, 'locked', None):
215             # If the session is still locked we release the lock
216             sess.session_storage.release_lock()
217         if getattr(sess, 'session_storage', None):
218             del sess.session_storage
219
220
221 class RamStorage:
222     """ Implementation of the RAM backend for sessions """
223    
224     def load(self, id):
225         return cherrypy._session_data_holder.get(id)
226    
227     def save(self, id, data, expiration_time):
228         cherrypy._session_data_holder[id] = (data, expiration_time)
229
230     def delete(self, id=None):
231         if id is None:
232             id = cherrypy.session.id
233         del cherrypy._session_data_holder[id]
234
235     def acquire_lock(self):
236         sess = cherrypy.request._session
237         id = cherrypy.session.id
238         lock = cherrypy._session_lock_dict.get(id)
239         if lock is None:
240             lock = threading.Lock()
241             cherrypy._session_lock_dict[id] = lock
242         startTime = time.time()
243         while True:
244             if lock.acquire(False):
245                 break
246             if time.time() - startTime > sess.deadlock_timeout:
247                 raise SessionDeadlockError()
248             time.sleep(0.5)
249         sess.locked = True
250    
251     def release_lock(self):
252         sess = cherrypy.request._session
253         id = cherrypy.session['_id']
254         cherrypy._session_lock_dict[id].release()
255         sess.locked = False
256    
257     def clean_up(self, sess):
258         to_be_deleted = []
259         now = datetime.datetime.now()
260         for id, (data, expiration_time) in cherrypy._session_data_holder.iteritems():
261             if expiration_time < now:
262                 to_be_deleted.append(id)
263         for id in to_be_deleted:
264             try:
265                 deleted_session = cherrypy._session_data_holder[id]
266                 del cherrypy._session_data_holder[id]
267                 sess.on_delete_session(deleted_session)
268             except KeyError:
269                 # The session probably got deleted by a concurrent thread
270                 #   Safe to ignore this case
271                 pass
272
273
274 class FileStorage:
275     """ Implementation of the File backend for sessions """
276    
277     SESSION_PREFIX = 'session-'
278     LOCK_SUFFIX = '.lock'
279    
280     def load(self, id):
281         file_path = self._get_file_path(id)
282         try:
283             f = open(file_path, "rb")
284             data = pickle.load(f)
285             f.close()
286             return data
287         except (IOError, EOFError):
288             return None
289    
290     def save(self, id, data, expiration_time):
291         file_path = self._get_file_path(id)
292         f = open(file_path, "wb")
293         pickle.dump((data, expiration_time), f)
294         f.close()
295    
296     def delete(self, id=None):
297         if id is None:
298             id = cherrypy.session.id
299         file_path = self._get_file_path(id)
300         try:
301             os.unlink(file_path)
302         except:
303             pass
304        
305     def acquire_lock(self):
306         sess = cherrypy.request._session
307         if not sess.locked:
308             file_path = self._get_file_path(cherrypy.session.id)
309             self._lock_file(file_path + self.LOCK_SUFFIX)
310             sess.locked = True
311    
312     def release_lock(self):
313         sess = cherrypy.request._session
314         file_path = self._get_file_path(cherrypy.session.id)
315         self._unlock_file(file_path + self.LOCK_SUFFIX)
316         sess.locked = False
317    
318     def clean_up(self, sess):
319         storage_path = cherrypy.config.get('session_filter.storage_path')
320         if storage_path is None:
321             return
322         now = datetime.datetime.now()
323         # Iterate over all files in the dir/ and exclude non session files
324         #   and lock files
325         for fname in os.listdir(storage_path):
326             if (fname.startswith(self.SESSION_PREFIX)
327                 and not fname.endswith(self.LOCK_SUFFIX)):
328                 # We have a session file: try to load it and check
329                 #   if it's expired. If it fails, nevermind.
330                 file_path = os.path.join(storage_path, fname)
331                 try:
332                     f = open(file_path, "rb")
333                     data, expiration_time = pickle.load(f)
334                     f.close()
335                     if expiration_time < now:
336                         # Session expired: deleting it
337                         id = fname[len(self.SESSION_PREFIX):]
338                         sess.on_delete_session(data)
339                         os.unlink(file_path)
340                 except:
341                     # We can't access the file ... nevermind
342                     pass
343    
344     def _get_file_path(self, id):
345         storage_path = cherrypy.config.get('session_filter.storage_path')
346         if storage_path is None:
347             raise SessionStoragePathNotConfiguredError()
348         fileName = self.SESSION_PREFIX + id
349         file_path = os.path.join(storage_path, fileName)
350         if not os.path.normpath(file_path).startswith(storage_path):
351             raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
352         return file_path
353    
354     def _lock_file(self, path):
355         sess = cherrypy.request._session
356         startTime = time.time()
357         while True:
358             try:
359                 lockfd = os.open(path, os.O_CREAT|os.O_WRONLY|os.O_EXCL)
360             except OSError:
361                 if time.time() - startTime > sess.deadlock_timeout:
362                     raise SessionDeadlockError()
363                 time.sleep(0.5)
364             else:
365                 os.close(lockfd)
366                 break
367    
368     def _unlock_file(self, path):
369         os.unlink(path)
370
371
372 class PostgreSQLStorage:
373     """ Implementation of the PostgreSQL backend for sessions. It assumes
374         a table like this:
375
376             create table session (
377                 id varchar(40),
378                 data text,
379                 expiration_time timestamp
380             )
381     """
382    
383     def __init__(self):
384         self.db = cherrypy.config.get('session_filter.get_db')()
385         self.cursor = self.db.cursor()
386    
387     def __del__(self):
388         if self.cursor:
389             self.cursor.close()
390         self.db.commit()
391    
392     def load(self, id):
393         # Select session data from table
394         self.cursor.execute(
395             'select data, expiration_time from session where id=%s',
396             (id,))
397         rows = self.cursor.fetchall()
398         if not rows:
399             return None
400         pickled_data, expiration_time = rows[0]
401         # Unpickle data
402         data = pickle.loads(pickled_data)
403         return (data, expiration_time)
404
405     def delete(self, id=None):
406         if id is None:
407             id = cherrypy.session.id
408         self.cursor.execute('delete from session where id=%s', (id,))
409    
410     def save(self, id, data, expiration_time):
411         # Try to delete session if it was already there
412         self.cursor.execute(
413             'delete from session where id=%s',
414             (id,))
415         # Pickle data
416         pickled_data = pickle.dumps(data)
417         # Insert new session data
418         self.cursor.execute(
419             'insert into session (id, data, expiration_time) values (%s, %s, %s)',
420             (id, pickled_data, expiration_time))
421    
422     def acquire_lock(self):
423         # We use the "for update" clause to lock the row
424         self.cursor.execute(
425             'select id from session where id=%s for update',
426             (cherrypy.session.id,))
427    
428     def release_lock(self):
429         # We just close the cursor and that will remove the lock
430         #   introduced by the "for update" clause
431         self.cursor.close()
432         self.cursor = None
433    
434     def clean_up(self, sess):
435         now = datetime.datetime.now()
436         self.cursor.execute(
437             'select data from session where expiration_time < %s',
438             (now,))
439         rows = self.cursor.fetchall()
440         for row in rows:
441             sess.on_delete_session(row[0])
442         self.cursor.execute(
443             'delete from session where expiration_time < %s',
444             (now,))
445
446
447 try:
448     os.urandom(20)
449 except (AttributeError, NotImplementedError):
450     # os.urandom not available until Python 2.4. Fall back to random.random.
451     def generate_session_id():
452         """Return a new session id."""
453         return sha.new('%s' % random.random()).hexdigest()
454 else:
455     def generate_session_id():
456         """Return a new session id."""
457         return os.urandom(20).encode('hex')
458
459 generateSessionID = generate_session_id
460
461 # Users access sessions through cherrypy.session, but we want this
462 #   to be thread-specific so we use a special wrapper that forwards
463 #   calls to cherrypy.session to a thread-specific dictionary called
464 #   cherrypy.request._session.session_data
465 class SessionWrapper:
466    
467     def __getattr__(self, name):
468         sess = cherrypy.request._session
469         if sess.session_storage is None:
470             raise SessionNotEnabledError()
471         # Create thread-specific dictionary if needed
472         session_data = getattr(sess, 'session_data', None)
473         if session_data is None:
474             sess.session_data = {}
475         if name == 'acquire_lock':
476             return sess.session_storage.acquire_lock
477         elif name == 'release_lock':
478             return sess.session_storage.release_lock
479         elif name == 'id':
480             return sess.session_id
481         elif name == 'delete':
482             return sess.session_storage.delete
483
484         if sess.to_be_loaded:
485             data = sess.session_storage.load(sess.session_id)
486             # data is either None or a tuple (session_data, expiration_time)
487             if data is None or data[1] < datetime.datetime.now():
488                 # Expired session:
489                 # flush session data (but keep the same session_id)
490                 sess.session_data