I have separated input validation and security into two classes for more clarity. InputUtility now only handles field type validation whereas SecUtility now takes care of encryption and such. I’ve also opted to encrypt (not hash) all user emails in the database for better security using the Web.config machineKey.
InputUtility class…
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Globalization; using System.Text; using System.Text.RegularExpressions; namespace Road.Helpers { public static class InputUtility { /// <summary> /// Gets an array of cleaned tags /// </summary> /// <param name="txt">A comma delimited string of tags</param> /// <returns>Array of cleaned tags</returns> public static string[] GetTags(string txt, bool lower = false) { string[] tags = txt.Split(','); List<string> clean = new List<string>(); for (int i = 0; i < tags.Length; i++) { tags[i] = GetFlatString(tags[i], " ").Trim(); tags[i] = new string(tags[i].Where(c => !char.IsPunctuation(c)).ToArray()); if (!string.IsNullOrEmpty(tags[i])) tags[i] = (lower) ? tags[i].ToLower() : tags[i]; // Don't want to repeat if (!clean.Contains(tags[i])) clean.Add(tags[i]); } return clean.ToArray(); } /// <summary> /// Checks whether string has value or sets default /// </summary> public static int GetInt(string val, int d, int? min) { int tmp = 0; if (!Int32.TryParse(val, out tmp)) tmp = d; if (min.HasValue) if (tmp <= min.Value) tmp = d; return tmp; } /// <summary> /// Checks whether nullable int has value or sets default /// </summary> public static int GetInt(int? val, int d, int? min) { val = val ?? d; if (min.HasValue) if (val.Value <= min.Value) val = d; return val.Value; } /// <summary> /// Checks whether nullable bool has value or sets default /// </summary> public static bool GetBool(bool? val, bool d) { val = val ?? d; return val.Value; } /// <summary> /// Checks whether nullable bool has value or sets default /// </summary> public static bool GetBool(string val, bool d) { bool tmp = d; if (Boolean.TryParse(val, out tmp)) return tmp; return d; } /// <summary> /// Returns a flat (no line breaks) string or a /// default value if empty /// </summary> public static string GetFlatString(string val, string d, int l = 255) { return InputUtility.GetString(val, d, l) .Replace(Environment.NewLine, ""); } /// <summary> /// Checks whether nullable string has value or sets default /// </summary> public static string GetString(string val, string d, int l = 255) { if (string.IsNullOrEmpty(val)) val = d; val.Replace("\r", Environment.NewLine) .Replace("\n", Environment.NewLine); if ((val.Length > 0) && (val.Length > l)) val = val.Substring(0, l - 1); return val; } /// <summary> /// Converts a string value to a DateTime object or returns /// the default value on failure /// </summary> public static DateTime GetDate(string val, DateTime d) { DateTime dt; if (DateTime.TryParse(val, out dt)) return dt; return d; } /// <summary> /// Converts a nullable date value to a DateTime object or /// returns the default value on failure /// </summary> public static DateTime GetDate(DateTime? val, DateTime d) { DateTime dt; dt = (val.HasValue) ? val.Value : d; return d; } public static string GetEmail(string v) { string email = @"^[\w!#$%&'*+\-/=?\^_`{|}~]+(\.[\w!#$%&'*+\-/=?\^_`{|}~]+)*" + @"@((([\-\w]+\.)+[a-zA-Z]{2,4})|(([0-9]{1,3}\.){3}[0-9]{1,3}))$"; if (!string.IsNullOrEmpty(v)) if (Regex.IsMatch(v, email)) return v; // Didn't match the email format, so sent a cleaned string return InputUtility.GetFlatString(v, ""); } } }
SecUtility class…
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.Security; using System.Security.Cryptography; using System.Globalization; using System.Text; namespace Road.Helpers { public static class SecUtility { // Password character range static string _range = "346789ABCDEFGHKMNPQRTUVWXYZ"; /// <summary> /// Compares a given password with one stored in the database and an optional salt /// </summary> public static bool CheckPassword(string username, string password, string dbpassword, string dbsalt) { if (dbpassword == HashPassword((username + password), dbsalt)) return true; return false; } /// <summary> /// Encodes a given password using BCrypt /// </summary> /// <param name="password">Password (plus salt as per above functions if necessary)</param> /// <returns>Hashed password.</returns> public static string HashPassword(string password, string salt) { BCryptHash hash = new BCryptHash(); hash.Key = salt; return Convert.ToBase64String(hash.ComputeHash(Encoding.Unicode.GetBytes(password))); } public static string Encrypt(string txt) { return MachineKey.Encode( Encoding.Unicode.GetBytes(txt), MachineKeyProtection.All); } public string Decrypt(string txt) { return Encoding.Unicode.GetString( MachineKey.Decode(txt, MachineKeyProtection.All)); } /// <summary> /// Generates a random salt using BCrypt /// </summary> /// <returns></returns> public static string GenerateSalt() { return BCrypt.Net.BCrypt.GenerateSalt(7); } /// <summary> /// Generates a random password of given length (MinRequiredPasswordLength) /// </summary> public string GeneratePassword(int len) { return GenerateRandomString(len, _range); } /// <summary> /// Generates a random string of given length /// </summary> /// <param name="len">Length of the generated string</param> /// <param name="range">Character pool</param> /// <returns>Generated string</returns> public static string GenerateRandomString(int len, string range) { Byte[] _bytes = new Byte[len]; char[] _chars = new char[len]; using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider()) { rng.GetBytes(_bytes); for (int i = 0; i < len; i++) _chars[i] = range[(int)_bytes[i] % range.Length]; } return new string(_chars); } /// <summary> /// Gets the current user's IP address /// </summary> /// <returns></returns> public string GetUserIP() { // Connecting through a proxy? string ip = HttpContext.Current.Request.ServerVariables["HTTP_X_FORWARDED_FOR"]; //None found if (string.IsNullOrEmpty(ip) || ip.ToLower() == "unknown") ip = HttpContext.Current.Request.ServerVariables["REMOTE_ADDR"]; return ip; } } }
Following Microsoft’s stupid update to the AntiXss library which breaks everything, I’m now using the older 4.0 library with the HtmlUtility class. Even with the vulnerability (the details of which MS hasn’t released except stating that it allows “information disclosure“) I figured that by limiting what the library does, we can still use the older library and still reduce the attack surface.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using HtmlAgilityPack; namespace Road.Helpers { /// <summary> /// This is an HTML cleanup utility combining the benefits of the /// HtmlAgilityPack to parse raw HTML and the AntiXss library /// to remove potentially dangerous user input. /// /// Additionally it uses a list created by Robert Beal to limit /// the number of allowed tags and attributes to a sensible level /// </summary> public static class HtmlUtility { // Original list courtesy of Robert Beal : // http://www.robertbeal.com/ private static readonly Dictionary<string, string[]> ValidHtmlTags = new Dictionary<string, string[]> { {"p", new string[] {"style", "class", "align"}}, {"div", new string[] {"style", "class", "align"}}, {"span", new string[] {"style", "class"}}, {"br", new string[] {"style", "class"}}, {"hr", new string[] {"style", "class"}}, {"label", new string[] {"style", "class"}}, {"h1", new string[] {"style", "class"}}, {"h2", new string[] {"style", "class"}}, {"h3", new string[] {"style", "class"}}, {"h4", new string[] {"style", "class"}}, {"h5", new string[] {"style", "class"}}, {"h6", new string[] {"style", "class"}}, {"font", new string[] {"style", "class", "color", "face", "size"}}, {"strong", new string[] {"style", "class"}}, {"b", new string[] {"style", "class"}}, {"em", new string[] {"style", "class"}}, {"i", new string[] {"style", "class"}}, {"u", new string[] {"style", "class"}}, {"strike", new string[] {"style", "class"}}, {"ol", new string[] {"style", "class"}}, {"ul", new string[] {"style", "class"}}, {"li", new string[] {"style", "class"}}, {"blockquote", new string[] {"style", "class"}}, {"code", new string[] {"style", "class"}}, {"pre", new string[] {"style", "class"}}, {"a", new string[] {"style", "class", "href", "title"}}, {"img", new string[] {"style", "class", "src", "height", "width", "alt", "title", "hspace", "vspace", "border"}}, {"table", new string[] {"style", "class"}}, {"thead", new string[] {"style", "class"}}, {"tbody", new string[] {"style", "class"}}, {"tfoot", new string[] {"style", "class"}}, {"th", new string[] {"style", "class", "scope"}}, {"tr", new string[] {"style", "class"}}, {"td", new string[] {"style", "class", "colspan"}}, {"q", new string[] {"style", "class", "cite"}}, {"cite", new string[] {"style", "class"}}, {"abbr", new string[] {"style", "class"}}, {"acronym", new string[] {"style", "class"}}, {"del", new string[] {"style", "class"}}, {"ins", new string[] {"style", "class"}} }; /// <summary> /// Takes raw HTML input and cleans against a whitelist /// </summary> /// <param name="source">Html source</param> /// <returns>Clean output</returns> public string SanitizeHtml(string source) { HtmlDocument html = GetHtml(source); if (html == null) return String.Empty; // All the nodes HtmlNode allNodes = html.DocumentNode; // Select whitelist tag names string[] whitelist = (from kv in ValidHtmlTags select kv.Key).ToArray(); // Scrub tags not in whitelist CleanNodes(allNodes, whitelist); // Filter the attributes of the remaining foreach (KeyValuePair<string, string[]> tag in ValidHtmlTags) { IEnumerable<HtmlNode> nodes = (from n in allNodes.DescendantsAndSelf() where n.Name == tag.Key select n); // No nodes? Skip. if (nodes == null) continue; foreach (var n in nodes) { // No attributes? Skip. if (!n.HasAttributes) continue; // Get all the allowed attributes for this tag HtmlAttribute[] attr = n.Attributes.ToArray(); foreach (HtmlAttribute a in attr) { if (!tag.Value.Contains(a.Name)) { a.Remove(); // Attribute wasn't in the whitelist } else { if (a.Name == "href" || a.Name == "src") { a.Value = (!string.IsNullOrEmpty(a.Value) && (a.Value.IndexOf("javascript") < 10 || a.Value.IndexOf("eval") < 10)) ? a.Value.Replace("javascript", "").Replace("eval", "") : a.Value; } else if (a.Name == "class" || a.Name == "style") { a.Value = Microsoft.Security.Application.Encoder.CssEncode(a.Value); } else { a.Value = Microsoft.Security.Application.Encoder.HtmlAttributeEncode(a.Value); } } } } } // Anything we missed will get stripped out return Microsoft.Security.Application.Sanitizer.GetSafeHtmlFragment(allNodes.InnerHtml); } /// <summary> /// Takes a raw source and removes all HTML tags /// </summary> /// <param name="source"></param> /// <returns></returns> public string StripHtml(string source) { source = SanitizeHtml(source); // No need to continue if we have no clean Html if (String.IsNullOrEmpty(source)) return String.Empty; HtmlDocument html = GetHtml(source); StringBuilder result = new StringBuilder(); // For each node, extract only the innerText foreach (HtmlNode node in html.DocumentNode.ChildNodes) result.Append(node.InnerText); return result.ToString(); } /// <summary> /// Recursively delete nodes not in the whitelist /// </summary> private static void CleanNodes(HtmlNode node, string[] whitelist) { if (node.NodeType == HtmlNodeType.Element) { if (!whitelist.Contains(node.Name)) { node.ParentNode.RemoveChild(node); return; // We're done } } if (node.HasChildNodes) CleanChildren(node, whitelist); } /// <summary> /// Apply CleanNodes to each of the child nodes /// </summary> private static void CleanChildren(HtmlNode parent, string[] whitelist) { for (int i = parent.ChildNodes.Count - 1; i >= 0; i--) CleanNodes(parent.ChildNodes[i], whitelist); } /// <summary> /// Helper function that returns an HTML document from text /// </summary> private static HtmlDocument GetHtml(string source) { HtmlDocument html = new HtmlDocument(); html.OptionFixNestedTags = true; html.OptionAutoCloseOnEnd = true; html.OptionDefaultStreamEncoding = Encoding.UTF8; html.LoadHtml(source); // Encode any code blocks independently so they won't // be stripped out completely when we do a final cleanup foreach (var n in html.DocumentNode.DescendantNodesAndSelf()) { if (n.Name == "code") n.InnerHtml = Microsoft.Security.Application.Encoder.HtmlEncode(n.InnerHtml); } return html; } } }
All of the repositories will be inheriting the following…
using System; using System.Collections.Generic; using System.Linq; using System.Web; using Road.Helpers; namespace Road.Models { public class BaseRepository { // Common DataContext public readonly CMDataContext db; public BaseRepository(CMDataContext _db) { db = _db; } #region Author/Creator Helpers /// <summary> /// Helper function generates a save friendly Author from a given Creator /// </summary> /// <param name="c">Creator data</param> /// <returns>Author object</returns> public static Author getAuthor(Creator c) { if (c == null) return null; Author author = new Author(); if (c.Id > 0) { author.AuthorId = c.Id; } else { author.AuthorEmail = c.Email; author.AuthorWeb = c.Web; } author.AuthorIP = c.IP; author.AuthorName = c.Name; return author; } /// <summary> /// Finds or creates a Creator object from given author information /// </summary> /// <param name="a">Saved author information</param> /// <param name="m">Optional membership information</param> /// <returns>Composite Creator object</returns> public static Creator getCreator(Author a, Member m) { Creator au = new Creator(); au.IP = a.AuthorIP; if (m != null) { au = getCreator(m); } else { au.LastModified = DateTime.MinValue; au.Id = a.AuthorId; au.Name = a.AuthorName; au.DisplayName = a.AuthorName; au.Email = a.AuthorEmail; au.Web = a.AuthorWeb; } return au; } /// <summary> /// Helper function generates a Creator object from membership info /// </summary> /// <param name="m">Member object</param> /// <returns>Composite Creator object</returns> public static Creator getCreator(Member m) { return new Creator { Id = m.MemberId, Name = m.Username, DisplayName = m.DisplayName, Email = m.Email, Web = m.Web, Slug = m.Slug, CreatedDate = m.CreatedDate, LastModified = m.LastActivity, Avatar = m.Avatar }; } #endregion } }
Can you forward your script to create that schema? Thanks!
Hey Sky,
Here you go…
(Please keep in mind that this schema may change quite a bit since it’s still a work in progress.)
USE [ContentManagement]
GO
/****** Object: Table [dbo].[Posts] Script Date: 03/08/2012 23:48:23 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Posts](
[PostId] [int] IDENTITY(1,1) NOT NULL,
[ParentId] [int] NOT NULL,
[Title] [nvarchar](100) NOT NULL,
[Slug] [nvarchar](100) NOT NULL,
[CreatedDate] [datetime] NOT NULL,
[LastModified] [datetime] NOT NULL,
[LastActivity] [datetime] NOT NULL,
[BodyText] [nvarchar](max) NOT NULL,
[BodyHtml] [nvarchar](max) NOT NULL,
[ContentHash] [nvarchar](max) NOT NULL,
[ViewCount] [int] NOT NULL,
[ReplyCount] [int] NOT NULL,
[IsApproved] [bit] NOT NULL,
[Status] [int] NOT NULL,
[Threshold] [float] NOT NULL,
CONSTRAINT [PK_Posts] PRIMARY KEY CLUSTERED
(
[PostId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Posts] ADD CONSTRAINT [DF_Posts_ParentId] DEFAULT ((0)) FOR [ParentId]
GO
/****** Object: Table [dbo].[PostTags] ******/
CREATE TABLE [dbo].[PostTags](
[TagId] [int] IDENTITY(1,1) NOT NULL,
[TagName] [nvarchar](80) NOT NULL,
[DisplayName] [nvarchar](100) NOT NULL,
[Slug] [nvarchar](80) NOT NULL,
[CreatedDate] [datetime] NOT NULL,
[LastModified] [datetime] NOT NULL,
[BodyText] [nvarchar](max) NULL,
[BodyHtml] [nvarchar](max) NULL,
[PostCount] [int] NOT NULL,
[IsApproved] [bit] NOT NULL,
[Status] [int] NOT NULL,
CONSTRAINT [PK_PostTags] PRIMARY KEY CLUSTERED
(
[TagId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[PostTagRelationships] ******/
CREATE TABLE [dbo].[PostTagRelationships](
[PostTagId] [int] IDENTITY(1,1) NOT NULL,
[TagId] [int] NOT NULL,
[PostId] [int] NOT NULL,
CONSTRAINT [PK_PostTagRelationships] PRIMARY KEY CLUSTERED
(
[PostTagId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[PostTagAuthorRelationships] ******/
CREATE TABLE [dbo].[PostTagAuthorRelationships](
[TagAuthorId] [int] IDENTITY(1,1) NOT NULL,
[TagId] [int] NOT NULL,
[AuthorId] [int] NOT NULL,
CONSTRAINT [PK_PostTagAuthorRelationships] PRIMARY KEY CLUSTERED
(
[TagAuthorId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[Authors] ******/
CREATE TABLE [dbo].[Authors](
[AuthorId] [int] IDENTITY(1,1) NOT NULL,
[AuthorName] [nvarchar](80) NOT NULL,
[AuthorIP] [nvarchar](100) NOT NULL,
[AuthorEmail] [nvarchar](500) NULL,
[AuthorWeb] [nvarchar](255) NULL,
[SessionHash] [nvarchar](500) NOT NULL,
[MemberId] [int] NOT NULL,
CONSTRAINT [PK_Authors] PRIMARY KEY CLUSTERED
(
[AuthorId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[PostAuthorRelationships] ******/
CREATE TABLE [dbo].[PostAuthorRelationships](
[PostAuthorId] [int] IDENTITY(1,1) NOT NULL,
[PostId] [int] NOT NULL,
[AuthorId] [int] NOT NULL,
CONSTRAINT [PK_PostAuthorRelationships] PRIMARY KEY CLUSTERED
(
[PostAuthorId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[Members] ******/
CREATE TABLE [dbo].[Members](
[MemberId] [int] IDENTITY(1,1) NOT NULL,
[Username] [nvarchar](80) NOT NULL,
[DisplayName] [nvarchar](100) NULL,
[Password] [nvarchar](500) NOT NULL,
[PasswordSalt] [nvarchar](100) NOT NULL,
[Email] [nvarchar](500) NOT NULL,
[Slug] [nvarchar](80) NOT NULL,
[CreatedDate] [datetime] NOT NULL,
[LastModified] [datetime] NOT NULL,
[LastActivity] [datetime] NOT NULL,
[Avatar] [nvarchar](255) NULL,
[Web] [nvarchar](255) NULL,
[Bio] [nvarchar](max) NULL,
[IsApproved] [bit] NOT NULL,
[IsLockedOut] [bit] NOT NULL,
CONSTRAINT [PK_Members] PRIMARY KEY CLUSTERED
(
[MemberId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO