Try our conversational search powered by Generative AI!

Giang Nguyen
Jul 23, 2021
  2953
(0 votes)

Get rid of Episerver Forms in-line scripts

Background

Inline JavaScript is discouraged to use nowadays due to security concerns and optimization requirements. In fact, inline script is a huge problem if a strict Content Security Policy header has to be applied.

However, Episerver (Optimizely) Forms still rendering inline JS inside HTML document. The script initializes and defines the whole form structure, fields, error messages, JS events etc. Thus, it must be included in the front-end page in order to make forms work.

Strategy

  1. Creating an MVC controller, which returns correct scripts by each form GUID
  2. Create a custom FormContainerBlock, which doesn't generating inline scripts
  3. Make use of RequiredClientResourceList service to render <script> tag at sufficient place in HTML document 

Step 1 - MVC Controllers

There are two different scripts which Forms depends on:

  • Initialization script: Initalize epi.EPiServer.Forms object with configurations in Forms.config and loads jQuery (if doesn't present on front-end)
  • "Requisite" script: This holds all information realted to the form, almost everything which has been set by editor in CMS

Initialization script be loaded in <head>, after jQuery and before "Requisite".

Initialize script (easy peasy part):

        private Injected<IEPiServerFormsImplementationConfig> _formConfig;

        [HttpGet]
        public string InitScript()
        {
            Response.StatusCode = 200;
            Response.ContentType = "text/javascript";
            return @"var epi = epi ||{ }; epi.EPiServer = epi.EPiServer ||{ }; epi.EPiServer.Forms = epi.EPiServer.Forms ||{ };
                    epi.EPiServer.Forms.InjectFormOwnJQuery = " + _formConfig.Service.InjectFormOwnJQuery.ToString().ToLowerInvariant() +
                    @";epi.EPiServer.Forms.OriginalJQuery = typeof jQuery !== 'undefined' ? jQuery : undefined";
        }

Requisite script (lengthy one):

Some built-in services you may want to know them first: FormResourceService, FormExtensions and LocalizationService (if the site is in multilingual context).

Since this script varies across different forms, therefore, the controller for this requires an ID or GUID parameter to pass in. After that, it's super important that you'll have to validate that the param refers to an actual and published form or not. Hereby, I use GUID because we can check for the validity of the param string before hitting DB.

        private Injected<FormResourceService> _formResourceService;
        private Injected<IContentLoader> _contentLoader;
        private Injected<LocalizationService> _localizationService;

        [HttpGet()]
        public string Requisite(string guid)
        {
            guid = guid ?? Request.QueryString["guid"];
            if (string.IsNullOrEmpty(guid))
            {
                Response.StatusCode = 404;
                return "//invalid";
            }

            Guid contentGuid;
            if (!Guid.TryParseExact(guid, "d", out contentGuid))
            {
                Response.StatusCode = 404;
                return "//invalid ";
            }

            var formContainerBlock = _contentLoader.Service.Get<IContent>(contentGuid) as FormContainerBlock;
            if (formContainerBlock == null)
            {
                Response.StatusCode = 404;
                return "//not found";
            }

            Response.StatusCode = 200;
            Response.ContentType = "text/javascript";

            // To-do: Generate script and
            return EPiServerForms_prerequisite_js;
        }

Firstly, generate script skeleton:

var EPiServerForms_prerequisite_js = 
    EPiServer.Forms.Helpers.Internal.ModuleHelper.GetWebResourceContent(typeof(FormContainerBlockController), "custom/path/to/resource.js");

Secondly, as we know, after execution of requisite script, it will load external CSS or JS, if configured. Therefore, we need to list those resources:

// 1st round: Built-in resources
List<string> scripts, css;
_formResourceService.Service.GetFormExternalResources(out scripts, out css);

// 2nd round: Extra (and configurable) resources
List<string> elementExtraScripts, elementExtraCss;
_formResourceService.Service.GetFormElementExtraResources(formContainerBlock, out elementExtraScripts, out elementExtraCss);

scripts.AddRange(elementExtraScripts);
css.AddRange(elementExtraCss);

Thirdly, acquire script and common messages:

var formLanguage = FormsExtensions.GetCurrentFormLanguage(formContainerBlock);
var currentPageLanguage = FormsExtensions.GetCurrentPageLanguage();
var commonMessagesObj = new
{
        viewMode = new
        {
                    malformStepConfiguration = _localizationService.Service.GetString("/episerver/forms/viewmode/malformstepconfigruation"),
                    commonValidationFail = _localizationService.Service.GetString("/episerver/forms/viewmode/commonvalidationfail"),
                },

                fileUpload = new
                {
                    overFileSize = _localizationService.Service.GetString("/episerver/forms/messages/fileupload/overFileSize"),
                    invalidFileType = _localizationService.Service.GetString("/episerver/forms/messages/fileupload/invalidfiletype"),
                    postedFile = _localizationService.Service.GetString("/episerver/forms/messages/fileupload/postedfile")
                }
};
var commonMessages = commonMessagesObj.ToJson();

Finally, replace "placeholders" in EPiServerForms_prerequisite_js at 1st step with acquired data (sorry in advance for the poor optimization):

EPiServerForms_prerequisite_js = EPiServerForms_prerequisite_js
                .Replace("___CurrentPageLink___", FormsExtensions.GetCurrentPageLink().ToString())
                .Replace("___CurrentPageLanguage___", currentPageLanguage)
                .Replace("___CurrentFormLanguage___", string.IsNullOrWhiteSpace(formLanguage) ? currentPageLanguage : formLanguage)
                .Replace("___ExternalScriptSources___", scripts?.ToJson())
                .Replace("___ExternalCssSources___", css?.ToJson())
                .Replace("___UploadExtensionBlackList___", _formConfig.Service.DefaultUploadExtensionBlackList)
                .Replace("___Messages___", commonMessages)
                .Replace("___LocalizedResources___", FormsExtensions.GetLocalizedResources().ToJson());

So, the string is ready to be returned.

Retouch

Create a custom path to make the scripts are devlivered in *.JS URL format.

    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Forms.InitializationModule))]
    public class RemoveInlineScriptInitialization : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            var initRouteData = new RouteValueDictionary();
            initRouteData.Add("Controller", "FormBlockScript");
            initRouteData.Add("Action", "InitScript");
            RouteTable.Routes.Add("FormBlockOriginalJqueryRoute",
                new Route("custom/path/to/form-initialization.js", initRouteData, new MvcRouteHandler()) { RouteExistingFiles = false });

            var requisiteRouteData = new RouteValueDictionary();
            requisiteRouteData.Add("Controller", "FormBlockScript");
            requisiteRouteData.Add("Action", "Requisite");
            RouteTable.Routes.Add("FormBlockRequisiteScriptGuidRoute",
                new Route("custom/path/to/form-requisite-{guid}.js", requisiteRouteData, new MvcRouteHandler()) { RouteExistingFiles = false });
        }
        public void Uninitialize(InitializationEngine context)
        {
            //Do nothing :)
        }
    }

Why don't just editing Global.asax? Well, init module may be handly next step.

Step 2 - Custom FormContainerBlock

Firstly, create a custom form block, inherits the built-in one (why not!).

    // The model
    [ContentType(GroupName = "FormsContainerElements", Order = 1000, DisplayName = "CSP-safe Form Block")]
    public class NoInlineScriptFormContainerBlock : FormContainerBlock
    {
        // We don't expect any difference in edit mode
    }

    // The controller
    [TemplateDescriptor(AvailableWithoutTag = true,
                Default = true,
                ModelType = typeof(NoInlineScriptFormContainerBlock),
                TemplateTypeCategory = TemplateTypeCategories.MvcPartialController)]
    public class NoInlineScriptFormContainerBlockController : FormContainerBlockController
    {
        public static readonly string INIT_SCRIPT = "/custom/path/to/form-initialization.js";
        public static readonly string REQUISITE_SCRIPT_TEMPLATE = "/custom/path/to/form-requisite-{0}.js";
        Injected<IEPiServerFormsImplementationConfig> _formConfig;

        public string REQUISITE_SCRIPT { get; private set; } // Read-only
        public override ActionResult Index(FormContainerBlock currentBlock)
        {
            REQUISITE_SCRIPT = string.Format(REQUISITE_SCRIPT_TEMPLATE, currentBlock.Content.ContentGuid.ToString("D"));
            return base.Index(currentBlock);
        }

        // To-do: Force the controller to load custom JS - not the ugly inline
    }

Retouch

We can block editor to use the default Form block, so, every form won't create inline scripts anymore. We can make use of the new init module have created recently:

var contentTypeRepository = ServiceLocator.Current.GetInstance<IContentTypeRepository>();
var defaultFormBlock = contentTypeRepository.Load<FormContainerBlock>();
if (defaultFormBlock != null && defaultFormBlock.IsAvailable)
{
           var clone = defaultFormBlock.CreateWritableClone() as ContentType;
           clone.IsAvailable = false;
           contentTypeRepository.Save(clone);
}

Step 3 - Load the JS files

Override the FormContainerBlockController.RegisterScriptResources, then load JS <script> tag into sufficient position in HTML doc using RequiredResourceList service:

        private Injected<IEPiServerFormsImplementationConfig> _formConfig;

        public override void RegisterScriptResources(FormContainerBlock formContainerBlock)
        {
            var requiredResources = ServiceLocator.Current.GetInstance<IRequiredClientResourceList>();

            // Add Init script
            requiredResources.RequireScript(INIT_SCRIPT, ConstantsForms.StaticResource.JS.EPiServerFormsSaveOriginaljQuery, new List<string> { }).AtHeader();
            requiredResources.RequireScript(REQUISITE_SCRIPT, ConstantsForms.StaticResource.JS.EPiServerFormsPrerequisite, new List<string> { ConstantsForms.StaticResource.JS.FormsjQuery }).AtHeader();

            // Add form-own jQuery (by configuration)
            if (_formConfig.Service.InjectFormOwnJQuery)
            {
                requiredResources.RequireScript(
                    ModuleHelper.GetWebResourceUrl(typeof(FormContainerBlockController), ConstantsForms.StaticResource.JS.FormsjQueryPath),
                    ConstantsForms.StaticResource.JS.FormsjQuery, new List<string> { ConstantsForms.StaticResource.JS.EPiServerFormsSaveOriginaljQuery }).AtHeader();
            }
            
            // Add requisite script
            requiredResources.RequireScript(
                ModuleHelper.GetWebResourceUrl(typeof(FormContainerBlockController), ConstantsForms.StaticResource.JS.EPiServerFormsMinifyPath), ConstantsForms.StaticResource.JS.EPiServerForms,
                new List<string> { ConstantsForms.StaticResource.JS.FormsjQuery, ConstantsForms.StaticResource.JS.EPiServerFormsRerequisite }).AtFooter();
        }

Afterwords

This may not well-optimized, and not tested throughly. Please use it at your own risk.
However, I think this is a quite easy and possible way if you're striggling when setting up CSP header or trying to pass a regular penetration test.

Jul 23, 2021

Comments

Please login to comment.
Latest blogs
Anonymous Tracking Across Devices with Optimizely ODP

An article by Lead Integration Developer, Daniel Copping In this article, I’ll describe how you can use the Optimizely Data Platform (ODP) to...

Daniel Copping | Apr 30, 2024 | Syndicated blog

Optimizely Forms - How to add extra data automatically into submission

Some words about Optimizely Forms Optimizely Forms is a built-in add-on by Optimizely development team that enables to create forms dynamically via...

Binh Nguyen Thi | Apr 29, 2024

Azure AI Language – Extractive Summarisation in Optimizely CMS

In this article, I demonstrate how extractive summarisation, provided by the Azure AI Language platform, can be leveraged to produce a set of summa...

Anil Patel | Apr 26, 2024 | Syndicated blog

Optimizely Unit Testing Using CmsContentScaffolding Package

Introduction Unit tests shouldn't be created just for business logic, but also for the content and rules defined for content creation (available...

MilosR | Apr 26, 2024

Solving the mystery of high memory usage

Sometimes, my work is easy, the problem could be resolved with one look (when I’m lucky enough to look at where it needs to be looked, just like th...

Quan Mai | Apr 22, 2024 | Syndicated blog

Search & Navigation reporting improvements

From version 16.1.0 there are some updates on the statistics pages: Add pagination to search phrase list Allows choosing a custom date range to get...

Phong | Apr 22, 2024