[Zope] possible security enhancement to CookieCutter product

Mark Hays hays@math.arizona.edu
Sat, 8 May 1999 18:03:21 -0700


--6TrnltStXW4iwmi0
Content-Type: text/plain; charset=us-ascii

I always enjoy Saturdays ;&)

Attached are some mods to CookieCutter.py -- my changes
start at "MIC stuff".

The overall idea is to make it "safe" to stick arbitrary
pickled python objects into cookies. And get them back
again intact (with verification), of course.

Comments/flames welcome,
Mark


--6TrnltStXW4iwmi0
Content-Type: text/plain; charset=us-ascii
Content-Disposition: attachment; filename="CookieCutter.py"

#
#  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:

            <!--#call "UserQuery.setRecord('Districts',_.None)"-->

        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.

            <!--#call "UserQuery.setRecord('Districts',(2,10,25))"-->
            <!--#call "UserQuery.setRecord('AreaName','Tokyo')"-->
            <!--#call "UserQuery.setCookie(RESPONSE)"-->


    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.

               <!--#call "UserQuery.getCookie()"-->
               <!--#var "UserQuery.records['AreaName']"-->

        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 ;^).
              
               <!--#call "UserQuery.mapRecords(REQUEST)"-->
               <!--#var AreaName-->


    Expiring cookies::

        Use expireCookie(RESPONSE). This sends a cookie using the
        cookie options with a date set to Jan 1, 1970.

               <!--#call "UserQuery.expireCookie(RESPONSE)"-->
    """

    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='<strong>%s</strong> 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)

--6TrnltStXW4iwmi0--