Security fix for the Language File Editor tool in EPiServer CMS 6 R2

In 2011 I wrote a tool allowing web administrators to edit EPiServer’s language XML files through admin mode. As the code was constructed it assumed that the environment was properly set up (i.e. securing the plugins preventing unauthorized access), thus trusting the user. Anyhow, failing to do so opened up for unauthorized users to read/modify/delete certain files on the server/shares. Here is a summary of the changes in security made to the plugin. New version available on GitHub.

Unauthorized visitor access to the Language File Editor

If access to the directory where you put the plugin (~/Plugins/ in this example) is not restricted it is possible for anyone to access it if they are aware of the proper URL to do so. In other words, a website visitor could potentially surf right into your http://yoursite.com/Plugins/LanguageFileEditor/LanguageFileEditor.aspx and start editing your language files. This is not a problem with how the Language File Editor is constructed but rather with the current setup of the environment. You should always make sure to prevent unauthorized access to sensitive directories. A quick way to do this for the Plugins directory is to add a location section in the web.config file.

web.config

<location path="Plugins">
  <system.web>
    <authorization>
      <allow roles="WebAdmins" />
      <deny users="*" />
    </authorization>
  </system.web>
</location>

Accessing files on the server

If a user gets so far as to have access to the previous version of the tool, it is possible for them to read, modify or delete files on your machine and/or network shares. It is not very likely that an intruder would know exactly what URLs to use, how you set up the tool or that you even use it, but better safe than sorry.

For instance, accessing /Plugins/LanguageFileEditor/UpdateLanguageFile.aspx, setting the ContentType to application/json and injecting bad paths like ../../web.config or the like into targetFilename and patternFilename could have serious consequenses.

Security fix for the issue with unauthorized access to the Language File Editor tool

There are now two new classes added to the plugin code; one for ensuring that the user is valid should you have forgot to secure your Plugins directory, and one for ensuring that you stay inside the proper language file directory.

UserValidator.cs

public static class UserValidator
{
  // You could let the exceptions thrown in this class be unhandled, and have ELMAH give you usernames, IP-addresses, and so forth.
  private static readonly ILog AuditLogger = LogManager.GetLogger(typeof(UserValidator));

  private static readonly IEnumerable<string> ValidRoles = new[]
  {
    "WebAdmins",
    "Administrators"
  };

  public static void EnsureValidRoles()
  {
    var context = HttpContext.Current;
    if (ValidRoles.Any(role => context.User.IsInRole(role)))
    {
      return;
    }
    var message = string.Format("Unauthorized access attempt to URL '{0}', see corresponding log handler for user IP etc.", context.Request.Url);
    AuditLogger.Warn(message);
    throw new UnauthorizedAccessException("Unauthorized access attempt to path.");
  }
}

Running EnsureValidRoles when entering either of the two plugin access points will make sure that only authenticated users with the roles WebAdmins or Administrators will be able to use the Language tool. If a visitor now tries to surf to the proper URL they will get an exception; or more likely a friendly error page.

Unauthorized access prevented in the EPiServer Language File Editor tool.

Security fix to prevent reading, modifying or deleting files on the server

In a similar manner there is a method for ensuring that the accessed path is inside the intended language directory.

PathValidator.cs

public static class PathValidator
{
  private static readonly ILog AuditLogger = LogManager.GetLogger(typeof(PathValidator));
  private static readonly IEnumerable<string> ValidDirectories = new[]
  {
    Normalize(LanguageManager.Instance.Directory .TrimEnd(System.IO.Path.DirectorySeparatorChar))
  };
  private static readonly IEnumerable<string> ValidExtensions = new[]
  {
    ".xml"
  };
        
  public static void EnsureValid(string path)
  {
    var normalizedPath = Normalize(path);
    if(IsFile(normalizedPath))
    {
      var file = normalizedPath
            .Split(System.IO.Path.DirectorySeparatorChar)
            .LastOrDefault();
      EnsureValidFile(file);
      normalizedPath = normalizedPath.TrimEnd(file);
    }
    EnsureValidDirectory(normalizedPath);
  }

  private static void EnsureValidDirectory(string normalizedPath)
  {
    normalizedPath = normalizedPath.TrimEnd(System.IO.Path.DirectorySeparatorChar);
    if (ValidDirectories.Any(p => p.Equals(normalizedPath)))
    {
      return;
    }
    var message = string.Format("Unauthorized access attempt to path '{0}'.", normalizedPath);
    AuditLogger.Warn(message);
    throw new UnauthorizedAccessException(message);
  }

  private static void EnsureValidFile(string file)
  {
    if(ValidExtensions.Any(file.EndsWith))
    {
      return;
    }
    var message = string.Format("Unauthorized access attempt to forbidden file '{0}'.", file);
    AuditLogger.Warn(message);
    throw new UnauthorizedAccessException(message);
  }

  private static bool IsFile(string path)
  {
    var attributes = File.GetAttributes(path);
    return (attributes & FileAttributes.Directory) != FileAttributes.Directory;
  }

  private static string Normalize(string path)
  {
    return System.IO.Path.GetFullPath(new Uri(path).LocalPath)
              .TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
              .ToLowerInvariant();
  }
}

The code is pretty straight forward; the accessed path is normalized before compared against a valid directory path. This ensures that the user is not trying to escape into other directories by using for instance ..\ or \\servername\. File extensions are also validated as all language files are XML files.

If I wrote this tool today I would probably never expose paths or filenames as input data, but rather assign each file a random id (likely a guid or something hard to guess) to pass back and forth.