# # Zope CookieCutter Product # # Manages cookies containing pickled dictionary data # by John Jarvis April 1, 1999 # # SECURITY WARNING # # It has been pointed out that accepting pickled objects over a network # presents a security risk. # # CookieCutter stores its objects by pickling them and sending them in a # cookie. The contents of the cookie when sent back by the client are # unpickled and copied into the CookieCutter object. THERE IS NO # AUTHENTICATION FUNCTIONALITY! # # The purpose of this product is to store multiple heterogenous data # types in a single cookie. It isn't a good idea to run code that has # been stored in a cookie--you don't know where it's been! # # JOHN JARVIS MAKES NO GUARANTEE AS TO THE SUITABILITY, SAFETY, OR SANITY # OF THIS SOFTWARE AND ACCEPTS NO RESPONSIBILITY FOR THE RESULTS (GOOD # OR BAD) OF YOUR USE OF IT. # # YOU HAVE BEEN WARNED! # import md5 import string import pickle from urllib import quote, unquote import Acquisition import AccessControl import OFS from Globals import HTMLFile, MessageDialog, Persistent class CookieCutter(Acquisition.Implicit, Persistent, AccessControl.Role.RoleManager, OFS.SimpleItem.Item): """Allows multiple data objects to be stored in a single cookie. Manages data records stored in a dictionary, pickled, and cookied. This allows multiple objects to be stored in a single cookie. Managing records(dictionary keys):: Use the management screen or CookieCutter.setRecord. For example, to dynamically add a record to a CookieCutter object called UserQuery: Note that the initial value can be anything. Storing objects:: Use CookieCutter.setRecord(key,value) where key is the dictionary key and value is the object to be stored. Note that the syntax is the same as REQUEST.set. Use CookieCutter.setCookie(RESPONSE) to store the dictionary in a cookie. Using stored objects:: There are two ways to do this: 1. Call CookieCutter.getCookie(REQUEST) to copy the cookie data to CookieCutter.records and access the values directly. 2. Call CookieCutter.mapRecords(REQUEST). This copies the objects in the records dictionary to the REQUEST namespace so that they can be used as normal attributes (comes in handy with SQLMethods). mapRecords calls getCookie so there is no need to do so yourself. Care must be taken to avoid conflicting names, ie. a key named 'name' is not a good idea (I did this once ;^). Expiring cookies:: Use expireCookie(RESPONSE). This sends a cookie using the cookie options with a date set to Jan 1, 1970. """ meta_type='CookieCutter' icon='misc_/CookieCutter/CookieCutter' #Tabs that appear along the top of the management interface manage_options=( {'label':'Properties','action':'manage_main'}, {'label':'View','action':''}, {'label':'Security','action':'manage_access'}, ) #Permissions related to editing. #Creation permitions go in __init__.py __ac_permissions__=( ('View management screens',('manage_tabs','manage_main')), ('Change permissions',('manage_access',)), ('Change Cookie',('manage_edit',),('Manager',)), ('View',('','getCookie','setCookie','setRecord','mapRecords','expireCookie')),) #Default object output index_html=HTMLFile('showCookieCutter',globals()) #Object editing interface manage_main=HTMLFile('editCookieCutter',globals()) #Note: records has to be a sequence item! def __init__(self,id,title,cookie_name,records,expires,domain,path,max_age,comment,secure): """Initialize object. records is a list of variable names. opts is a dict of cookie options--see REQUEST.setCookie for details. """ self.id=id self.title=title self.cookie_name=cookie_name self.opts={'expires':expires,'domain':domain,'path':path,'max_age':max_age,'comment':comment,'secure':secure} self.records={} #Add record attributes and initialize to None for i in records: if i!='': self.records[i]=None def manage_edit(self,title,cookie_name,records,expires,domain,path,max_age,comment,secure='',REQUEST=None): """Edit cookie record list. Attributes are simply replaced with new list entered. """ self.title=title self.cookie_name=cookie_name self.opts={'expires':expires,'domain':domain,'path':path,'max_age':max_age,'comment':comment,'secure':secure} #erase records deleted from list for i in self.records.keys(): if not i in records: del self.records[i] #add new records and initialize to None #check for empty lines (old and new) and erase for i in records: if i=='' and self.records.has_key(i): del self.records[i] elif i!='' and not self.records.has_key(i): self.records[i]=None #Inform user of success if called from dtml if REQUEST is not None: return MessageDialog( title='Edited', message='%s has been edited.' % self.id, action ='./manage_main', ) def resetRecords(self): """Sets all record objects to None """ for k in self.records.keys(): self.records[k]=None def setRecord(self,key,value): """Set value of a record. key=records dictionary key value=object to set """ self.records[key]=value ################################################## ### MIC stuff ### def EmacsBait(self): pass ### ### The problem described in WARNING.txt boils down ### to this: CookieCutter pickles some data and stores ### it in a cookie. Later, the browser is supposed to ### send this data back to CookieCutter; however, ### there is no guarantee that what comes back is the ### same as what was sent -- and so CookieCutter might ### end up unpickling who-knows-what. ### ### The goal of these mods is to try to ensure that ### what the browser sends back is identical to what ### we gave it -- and is therefore safe to unpickle. ### Well, as "safe" as the pre-pickled data we started ### with... ### ### This is accomplished by computing a Message ### Integrity Code (MIC) on the quoted pickled data. ### When CookieCutter sends data to the browser, it ### pickles+quotes the object, computes the MIC, and ### sends the MIC along with the pickled data. When ### the browser sends the cookie back to CookieCutter, ### we compute a new MIC of the pickled data and ### compare it to the original MIC (also obtained ### from the browser). If the MICs match, fine; ### otherwise, we _do not_ dequote/unpickle the data. ### ### The thing that makes this work is simple: only ### the CookieCutter can generate valid MICs. If ### you fiddle with the pickled data, the MICs won't ### match. If you fiddle with the MIC, you are groping ### in the dark. Either way, you have a probability ### of 2 ** -64 of fooling the MIC check. Which ain't ### bad -- if someone hates you that much, there probably ### isn't much you can do about it, anyway. ### ### There are no user- or Zope admin- visible changes. ### ### My yacking is much longer than the code that was ### added: two new methods (GenMIC and CheckMIC) and ### small changes to setCookie and getCookie. Oh, and ### the all-important SECRET. ### ### I'd appreciate feedback on this -- in case I'm being ### a knucklehead ;&) ### ### -- Mark ### ### PS The CookieCutter is _very_ cool, John! ### ################################################## ### ### Hays' DISCLAIMER: WARNING.txt is still in effect! ### So don't blame me, either. Unless you've written a ### book on computer security, don't even think about ### trusting my code. I have not written a book -- I ### only read them. Sometimes I even understand them. ### ################################################## ### ### Ideas from Kaufman, Perlman, Speciner, ``Network ### Security: Private Communication in a Public World'' ### Prentice Hall, 1995. ### ################################################## ### CONFIGURATION: ### ### A long, hard to guess string unique to your Zope installation. ### In other words, CHANGE THIS ;&) ### SECRET = "bf7774660a52c0ad899e5d7d29f2f76106c3865681bc35cdf9451efa1c3a377f" ### ### You probably DO NOT: ### - want SECRET to be web accessible (ie, part of Zope) ### - want CookieCutter.py to be readable by other users ### Reason: if somebody else knows SECRET, they can forge ### MICs and get you to unpickle arbitrary strings. ### ### It is simple to to dynamically generate SECRET. Under ### Linux, an MD5 hash of /proc/interrupts + /proc/meminfo ### at import time might be good enough: ### ###SECRET=md5.md5(open("/proc/interrupts", "r").read()).digest()+md5.md5(open("/proc/meminfo", "r").read()).digest() ### ### This will give you a new SECRET every time you restart ### Zope -- it will also invalidate _all_ existing cookies. ### ### You can set SECRET in __init__, too, if you want ### CookieCutters that _cannot_ talk to one another. ### Generate a MIC for a string -- returns 16 hex digits def GenMIC(self, s): ### hash the secret w/ the string d = map(ord, md5.md5(self.SECRET + s).digest()) ### do the irreversible-hash-swizzle-thing e = map(lambda x,y: x ^ y, d[:8], d[8:]) ### convert to hex return string.join(map(lambda x: "%02x" % x, e), "") ### In: 16 hex digit MIC + message ### Out: The message, if the MIC checks out or ### None otherwise. def CheckMIC(self, s): ### sanity check if len(s) < 16: return None ### extract pieces mic = s[:16] s = s[16:] ### compare received MIC against computed MIC if mic != self.GenMIC(s): return None ### looks good -- return quoted pickled cookie return s ### End of MIC stuff ################################################## def setCookie(self, RESPONSE, value=None, expires=None): """Pickle dictionary object in a cookie. Set value and expires if values other than defaults are required. This is used by expireCookie(). """ #shamelessly swiped from Request.py and modified #to deal with my mess cookies=RESPONSE.cookies if cookies.has_key(self.cookie_name): cookie=cookies[self.cookie_name] else: cookie=cookies[self.cookie_name]={} for k,v in self.opts.items(): if v: cookie[k]=v if value == None: ### compute a MIC for the pickled data; send MIC and ### raw data to the browser... we'll verify the ### integrity of the data before unpickling below. s = quote(pickle.dumps(self.records,bin=1)) cookie['value'] = self.GenMIC(s) + s else: ### Don't MIC in this case -- expireCookie() gets us here, e.g. ### Hopefully there isn't a back door here?? cookie['value']=value if expires != None: cookie['expires']=expires def getCookie(self,REQUEST): """Extract a pickled dictionary from a cookie. Since this is called by mapRecords, there really is no reason to call this on its own. """ self.resetRecords() #Dictionary records not listed in self.records are ignored!! #Records not found in cookie are set to None if REQUEST.cookies.has_key(self.cookie_name): ### extract the MIC from the cookie value & check it s = self.CheckMIC(REQUEST.cookies[self.cookie_name]) if s is None: ### bogosity alarm -- someone's screwing around import sys sys.stderr.write("Got moldy cookie -- ignoring.\n") return ### OK -- the pickled data is the same as what we ### sent... do the deed. ckd=pickle.loads(unquote(s)) ckk=ckd.keys() for k,v in ckd.items(): if self.records.has_key(k): self.records[k]=v for k in self.records.keys(): if k not in ckk: self.records[k]=None def mapRecords(self,REQUEST): """Copy the records into the REQUEST namespace """ self.getCookie(REQUEST) for k,v in self.records.items(): REQUEST.set(k,v) def expireCookie(self,RESPONSE): """Expire the client's cookie """ self.setCookie(RESPONSE, value='expired',expires='Tuesday, 1 Jan 1970 01:01:01 GMT') #Add new object user interface form addCookieCutterForm=HTMLFile('addCookieCutter',globals()) def addCookieCutter(self,id,title,cookie_name,records,expires,domain,path,max_age,comment,secure='',REQUEST=None): """Add a CookieCutter method """ #Create a new object instance obj=CookieCutter(id,title,cookie_name,records,expires,domain,path,max_age,comment,secure) #Add reference to self self._setObject(id,obj) #Go back to editing screen if REQUEST is not None: return self.manage_main(self,REQUEST)