Friday, October 21, 2011

Protects ClickOnce using ASP.NET Forms Authentication

"ClickOnce does not support ASP.NET forms-based authentication because it uses persistent cookies; these present a security risk because they reside in the Internet Explorer cache and can be hacked. Therefore, if you are deploying ClickOnce applications, any authentication scenario besides Windows authentication is unsupported."

- from Securing ClickOnce Applications by Microsoft

Even though Microsoft says that ClickOnce doesn't support ASP.NET Forms Authentication, there is a workaround.

Understanding How a ClickOnce Application is Downloaded

When access the ClickOnce deployment manifest (.application) file from IE, files are downloaded in following sequence:

  1. IE downloads the .application file.
  2. ClickOnce engine takes over the control and downloads the .application file again from the same URL.
  3. ClickOnce engine downloads the .application file from the update location (deploymentProvider codebase in the .application file).
  4. ClickOnce engine downloads the application manifest (.manifest) file specified in the .application file.
  5. ClickOnce engine downloads the files specified in the .manifest file using relative path based on the URL of the.manifest file.

As the ClickOnce engine doesn't support cookies, without a workaround step 2 will fail.

The Solution

You can find a workaround in this article: "Make ClickOnce Work With ASP.NET Forms Authentication". The trick is the Cookieless Forms Authentication introduced since ASP.NET 2.0. Please be aware that demo code from that article is for WPF Brower Application (.xbap), which don't have a "update location" in the .xbap file, therefore no step 3. For WinForm s/WPF applications, the workaround will fail in step 3 as IIS cannot find the Forms Authentication ticket from the URL. So in step 2, there is a need to embed the ticket into the deploymentProvider codebase, as same as the .manifest file URL, using the DeploymentUrl. As cookie is not supported, in step2, ticket(FormsIdentity) should be retrieved from context.User.Identity. Ticket can be embedded as either path or query string.

The configuration in the demo code is for IIS7.0 Classic mode as specified by the preCondition. For IIS7.0 Integrated mode, the configuration is:

<handlers>
  <add name="Clickonce manifest file" path="*.application" verb="*" type="ClickOnceHandler.ClickOnceApplicationHandler,ClickOnceHandler" resourceType="Unspecified" preCondition="" />
  <add name="Clickonce files" path="*.deploy" verb="*" type="System.Web.StaticFileHandler" resourceType="Unspecified" preCondition="" />
</handlers>

Please see this article for more information about the configuration: ASP.NET Integration with IIS 7.

Upgrade Consideration

When a checks for update, the ClickOnce engine will access the .application file and the .manifest file.

If these files are protected, you will need to get a Forms Authentication ticket and embed it in the URL to access them, otherwise, you need to detect the update by your own code.

It should be acceptable to leave these two types of files unprotected (as configured above). Even though, you still need a ticket to upgrade the application.

 The Pitfalls

While Forms Authentication ticket is encrypted then embedded into the URL, the length of the URL path is quite easy to exceed 260 characters. This may cause HTTP 400 Bad Request error.This could be the limitation of ASP.NET 2.0 and/or IIS. It recommends to upgrade to ASP.NET 4.0, and increase the UrlSegmentMaxLength registry value if there is a need - see more details: Http.sys registry settings for IIS.

The configuration in web.config must be consistent with the Application Pool setting (Integrated/Classic), otherwise the configuration will not take effects.

8 comments:

  1. Do you have any code? I'm having a hard time getting this to work. Thanks.

    ReplyDelete
    Replies
    1. You can get code from the linked articles.
      Check IIS logs and using Fiddler2 will help.

      Delete
  2. i followed your instructions but couldn't make it work in VS 2012.
    Could you please explain more details?

    ReplyDelete
    Replies
    1. Tell me where you stuck and what investigation you have done, possibly I can give you some suggestions.

      Delete
  3. I attach here my entire solution. could you please take a look? This problem take me lot of time but i haven't seen any light yet.

    https://drive.google.com/file/d/0B0_ADcKu9MzMVFhHbkhzeDVtTGc/edit?usp=sharing

    ReplyDelete
    Replies
    1. As I wrote "As cookie is not supported, in step2, ticket(FormsIdentity) should be retrieved from context.User.Identity."

      _val = pContext.Request.Cookies.Item(FormsAuthentication.FormsCookieName).Value

      Should be changed to:

      Dim cookie As HttpCookie
      cookie = pContext.Request.Cookies.Item(FormsAuthentication.FormsCookieName)
      If cookie Is Not Nothing Then
      _val = cookie.Value
      Else
      Dim formsIdentity = TryCast(context.User.Identity, FormsIdentity)
      If formsIdentity IsNot Nothing Then
      _val = FormsAuthentication.Encrypt(formsIdentity.Ticket)
      End If
      End If

      Good Luck!

      Delete
    2. HI Bob,

      Thanks so much for your feedback but i still couldn't make it work.
      After follow your step, it throw exception bellow:

      The remote server returned an error: (500) Internal Server Error.
      - Source: System
      - Stack trace:
      at System.Net.HttpWebRequest.GetResponse()
      at System.Xml.XmlDownloadManager.GetNonFileStream(Uri uri, ICredentials credentials, IWebProxy proxy, RequestCachePolicy cachePolicy)
      at System.Xml.XmlUrlResolver.GetEntity(Uri absoluteUri, String role, Type ofObjectToReturn)
      at System.Xml.XmlTextReaderImpl.OpenAndPush(Uri uri)
      at System.Xml.XmlTextReaderImpl.PushExternalEntityOrSubset(String publicId, String systemId, Uri baseUri, String entityName)
      at System.Xml.XmlTextReaderImpl.DtdParserProxy_PushExternalSubset(String systemId, String publicId)
      at System.Xml.DtdParser.ParseExternalSubset()
      at System.Xml.DtdParser.Parse(Boolean saveInternalSubset)
      at System.Xml.DtdParser.System.Xml.IDtdParser.ParseInternalDtd(IDtdParserAdapter adapter, Boolean saveInternalSubset)
      at System.Xml.XmlTextReaderImpl.ParseDtd()
      at System.Xml.XmlTextReaderImpl.ParseDoctypeDecl()
      at System.Xml.XmlTextReaderImpl.ParseDocumentContent()
      at System.Deployment.Application.ManifestValidatingReader.XmlFilteredReader.Read()
      at System.Xml.XmlCharCheckingReader.Read()
      at System.Xml.XsdValidatingReader.Read()
      at System.Deployment.Application.ManifestReader.FromDocument(String localPath, ManifestType manifestType, Uri sourceUri)
      at System.Deployment.Application.DownloadManager.DownloadDeploymentManifestDirect(SubscriptionStore subStore, Uri& sourceUri, TempFile& tempFile, IDownloadNotification notification, DownloadOptions options, ServerInformation& serverInformation)
      at System.Deployment.Application.DownloadManager.FollowDeploymentProviderUri(SubscriptionStore subStore, AssemblyManifest& deployment, Uri& sourceUri, TempFile& tempFile, IDownloadNotification notification, DownloadOptions options)
      at System.Deployment.Application.DownloadManager.DownloadDeploymentManifestBypass(SubscriptionStore subStore, Uri& sourceUri, TempFile& tempFile, SubscriptionState& subState, IDownloadNotification notification, DownloadOptions options)
      at System.Deployment.Application.ApplicationActivator.PerformDeploymentActivation(Uri activationUri, Boolean isShortcut, String textualSubId, String deploymentProviderUrlFromExtension, BrowserSettings browserSettings, String& errorPageUrl)
      at System.Deployment.Application.ApplicationActivator.ActivateDeploymentWorker(Object state)

      Delete
    3. Please using logging/tracing to dianose; also check Windows Event Logs

      Delete