Saturday, April 21, 2012

Override SPContext.Current.Web and Site with sites opened with elevated privileges

If you work with Sharepoint you most probably use SPSecurity.RunWithElevatedPrivileges() method in order to perform actions under high privileged account (Sharepoint\System or account of app pool of your Sharepoint web application in IIS). Very often however inside your delegate which is passed to RunWithElevatedPrivileges() method you call Sharepoint API which may use SPContext.Current.Site or SPContext.Current.Web. These SPSite and SPWeb instances in most cases are not opened with elevated privileges and you will get famous Access denied exception. Is there any workaround for this problem? Yes it is and in this post I will show how to override SPContext.Current.Site and SPContext.Current.Web with elevated versions.

First of all let’s check implementations of SPContext.Current.Site and SPContext.Current.Web. SPContext.Current returns instance of SPContext class as you probably would expect, so let’s start with SPContext.Site:

   1: public SPSite Site
   2: {
   3:     get
   4:     {
   5:         return this.Web.Site;
   6:     }
   7: }

As you can see internally it uses SPContext.Web property, so we can concentrate only on that. Let’s check its implementation:

   1: public SPWeb Web
   2: {
   3:     get
   4:     {
   5:         if (this.m_web == null)
   6:         {
   7:             this.m_web = SPControl.GetContextWeb(this.m_context);
   8:         }
   9:         return this.m_web;
  10:     }
  11: }

It calls SPControl.GetContextWeb() which in turn calls SPWebEnsureSPControl() method:

   1: private static SPWeb SPWebEnsureSPControl(HttpContext context)
   2: {
   3:     SPWeb web = (SPWeb) context.Items["HttpHandlerSPWeb"];
   4:     if (web == null)
   5:     {
   6:         SPSite site;
   7:         if (context.User == null)
   8:         {
   9:             throw new InvalidOperationException();
  10:         }
  11:         if (SPSecurity.ImpersonatingSelf || (SPSecurity.UserToken != null))
  12:         {
  13:             throw new InvalidOperationException();
  14:         }
  15:         context.Items["HttpHandlerSPSite"] =
  16:             site = new SPSite(SPFarm.Local, SPAlternateUrl.ContextUri, true);
  17:         web = site.OpenWeb();
  18:         context.Items["HttpHandlerSPWeb"] = web;
  19:         site.InitUserToken(web.Request);
  20:         SPRequestModule.InitContextWeb(context, web);
  21:         if (SPContext.GetShouldInitThreadCultureWhenContextWebIsInited(context))
  22:         {
  23:             web.SetThreadCultureAfterInit();
  24:         }
  25:     }
  26:     return web;
  27: }

Now we have all information in order to override SPContext.Current.Web. We need to store elevated instance os SPWeb in HttpContext.Items[“HttpHandlerSPWeb”] and force Sharepoint to call SPWebEnsureSPControl() method. First of all we need to set private variable m_web in SPContext site to null. As shown in listing of SPContext.Web it will call SPControl.GetContextWeb() only when it is null. After that the only thing to do is to override web site in HttpContext.Items collection with elevated version. Here is the code which makes the trick:

   1: SPSecurity.RunWithElevatedPrivileges(
   2: () =>
   3: {
   4:     using (var elevatedSite = new SPSite(SPContext.Current.Site.ID,
   5: SPContext.Current.Site.Zone))
   6:     {
   7:         using (var elevatedWeb = elevatedSite.OpenWeb(SPContext.Current.Web.ID))
   8:         {
   9:             var origSite = HttpContext.Current.Items["HttpHandlerSPSite"] as SPSite;
  10:             var origWeb = HttpContext.Current.Items["HttpHandlerSPWeb"] as SPWeb;
  11:             var privatem_web = ReflectionHelper.GetPrivateField(SPContext.Current,
  12:                 "m_web") as SPWeb;
  13:  
  14:             try
  15:             {
  16:                 HttpContext.Current.Items["HttpHandlerSPSite"] = elevatedSite;
  17:                 HttpContext.Current.Items["HttpHandlerSPWeb"] = elevatedWeb;
  18:                 ReflectionHelper.SetPrivateField(SPContext.Current, "m_web", null);
  19:  
  20:                 // perform calls to Sharepoint APIs here which uses
  21:                 // SPContext.Current.Web and SPContext.Current.Site
  22:             }
  23:             finally
  24:             {
  25:                 HttpContext.Current.Items["HttpHandlerSPSite"] = origSite;
  26:                 HttpContext.Current.Items["HttpHandlerSPWeb"] = origWeb;
  27:                 ReflectionHelper.SetPrivateField(SPContext.Current,
  28:                     "m_web", privatem_web);
  29:             }
  30:         }
  31:     }
  32: });

On lines 9,10 we store current values from HttpContext.Items collection. On line 11 we also store current value in m_web private variable in order to restore it later (this is not necessary actually – see below). Then on lines 16,17 we override values in HttpContext.Items with elevated SPSite and SPWeb. On line 18 we set SPContext.m_web to null using ReflectionHelper class (see below). After that we can call Sharepoint API which uses SPContext.Current.Web and Site and if originally we got Access denied error, now it will work successfully. In finally block we restore original values in order to minimize affected surface of our workaround. We set private m_web to original value, but we can also set it to null here – Sharepoint will retrieve original value from HttpContext.Items[“HttpHandlerSPWeb”].

Here is the code of ReflectionHelper which is used in example above (if you read my blog you may find it in other posts, but I decided to add it here as well in order to have complete example in one place):

   1: public static class ReflectionHelper
   2: {
   3:     public static void SetPrivateField(object obj, string name,
   4:         object val)
   5:     {
   6:         SetPrivateField(obj, obj.GetType(), name, val);
   7:     }
   8:  
   9:     public static void SetPrivateField(object obj, Type type, string name,
  10:         object val)
  11:     {
  12:         BindingFlags bf = 0 | BindingFlags.Instance | BindingFlags.Static |
  13:             BindingFlags.Public | BindingFlags.NonPublic;
  14:         FieldInfo mi = type.FindMembers(MemberTypes.Field, bf,
  15:             Type.FilterName, name)[0] as FieldInfo;
  16:         mi.SetValue(obj, val);
  17:     }
  18:  
  19:     public static object GetPrivateField(object obj, string name)
  20:     {
  21:         BindingFlags bf = 0 | BindingFlags.Instance | BindingFlags.Static |
  22:             BindingFlags.Public | BindingFlags.NonPublic;
  23:         FieldInfo mi = obj.GetType().FindMembers(MemberTypes.Field, bf,
  24:             Type.FilterName, name)[0] as FieldInfo;
  25:         return mi.GetValue(obj);
  26:     }
  27: }

Of course technique shown in this article should be treated as hack and you should use it only if standard ways didn’t work for you. Also I can’t predict all side effects which may be caused by this trick, however as always it is nice to have plan B if there are no other ways to proceed.

3 comments:

  1. Hi
    Nice trick ! :) Thanks for sharing !
    It remember me a post I've wrote (in french sorry) about how to have a valid SPContext in the context of a Console application (like a custom batch) : http://www.paslatek.net/spcontext-et-spservicecontext-dans-le-mauvais-contexte-ou-le-piegravege-du-singleton-masqueacute-20111102-64.aspx
    At the end of the article there is an extension method to allow to ensure SPContext instanciation from a SPSite or SPWeb...

    ReplyDelete
  2. Hi Lionel,
    in most of examples which I saw similar trick it was used to get SPContext where it was null initially, but not for replacing of SPWeb in it. That's why I thought that it can be useful and shared it. But thanks for your link, even if it was in French we all can read code.

    ReplyDelete
  3. Hi ,
    Very nice work !

    Sorry for the recursive submit but the computer of my work not responsive

    ReplyDelete