通过 HMAC 访问控制检查 URL





2.00/5 (2投票s)
2004年10月16日
5分钟阅读

29444
一篇关于合理化受保护ASP.NET资源的某些访问检查的文章,同时保持客户端可缓存性。
引言
这是一种(非完整实现)的方法,通过对实际传递内容的页面URL进行签名,从而减轻了托管通过ASP.NET传递的受保护对象的数据库服务器的负担(Web服务也可以正常工作)。
示例:一个访问检查非常复杂的网站。浏览时,成员只能看到他们有权访问的对象,因此我们在创建 img
-HREF
时就知道该成员确实有权访问,这就是为什么我们可以对 HREF
进行签名,以便在传递缩略图预览时无需再次检查。
注意:我将交替使用“HMAC”和“签名”这两个术语,仅指HMAC。本文完全不涉及真实签名。
背景
我有一个照片网站,成员可以向有限的受众发布照片。以一种(我认为是)非常复杂的方式。照片可以放入几个类别,这些类别可能继承不同级别的成员组权限。这使得访问查找非常缓慢。尽管如此,我已经对数据库进行了一些优化,以便在检查访问权限时可以忽略继承,因为每个类别的权限都已提前计算。每个用户的最终权限也已计算。
问题在于,当用户浏览某个类别时,服务器首先必须列出用户在该特定类别中有权访问的所有照片,然后会下载所有预览图像,导致服务器需要再次执行相同的操作。更糟糕的是,由于预览图像不以数组形式提供,因此必须为每个预览图像进行单独的查询。
解决方案
简单的解决方案是在将所有图像的URL发送到客户端之前对其进行签名。
一个随机且密码学强度高的密钥存储在session对象中,并在整个session期间保留。然后,可以对blobID以及过期时间(以ticks为单位)进行签名,并将其附加到URL中。然后,当服务器收到带有签名的预览图像请求时,无需查询数据库即可进行验证。
在我的例子中,访问信息与具有相关blob对象的照片对象相关联。此方法还省去了我查找目标blob ID的麻烦。
href=http://myserver.com/blob.aspx?blobid={guid}&validity=ticks&signature=signature
在我看来,有三种签名验证失败的情况。
- 过期超时,很正常。这里没有红灯,当用户以某种方式保存HTML时会发生这种情况。返回一个描述性的图像,要求用户刷新页面。
- session中没有密钥。(现在没有理由创建一个。节省周期。)虽然这可能是出了问题的迹象,但我们不能得出任何结论,因为当用户不支持你的session方案时会发生这种情况。(用户可能禁用了cookies。)这也不是问题,因为如果没有用户登录,就没有需要签名的权限。
- 签名无效。这也可能是一个合法的情况,所以不要惊慌,但要小心。
这个方案的一个相当大的缺点是客户端缓存,幸运的是可以解决。这里的客户端缓存对每个人都有好处,如果我们不断地为同一事物提供不同的URL,那将是不可能的。即使我们手动设置了到期日期。
解决客户端缓存问题的方法是按较大的步长递增到期日期。几分钟或几小时。显然,通过给予用户非常长的到期时间并不会损害安全性,因为签名会随着session而失效。而且,你永远无法阻止用户将图片保存到他们的硬盘驱动器。
这个方案还有一个额外的好处,那就是它可以轻松地优雅地扩展以支持负载均衡。任何能够访问session数据的服务器都可以检查访问的有效性。(session密钥可以集中存储。)你甚至可以使用完全独立的系统来传递实际数据,只要它们被正确签名。
不幸的是,大约每百个URL就会干扰标准的ASP.NET 1.1请求验证。自动请求验证是好的,但也有一些非常糟糕的一面。例如,当查询字符串包含转义字符时,比如这里非常常见的瑞典语 å、ä 和 ö,它就会抛出异常。此外,有时,也会出现有效的签名。
要关闭自动输入验证,请在web.config中输入 <pages validateRequest="false"/>
。你真的应该知道自己在做什么,并将此设置(使用<location>
标签)限制在你确定可以自行验证的页面。
最后,我知道我不应该假设这是万无一失的,因为我不是安全专家。相反,我非常感谢任何关于这个方案安全性的评论。
使用代码
这篇文章没有工作示例,因为我的实现相当具体,但应该很容易调整以适应其他实现。
我将把提供更通用的解决方案留给未来的更新。
// Example code for putting in a resource lister control.
// The blobIds must already be access checked, since their content is
// by signature granted to the browser.
public void ListResources(TextWriter writer, Guid[] blobIds)
{
string[] signedBlobUrls =
Hallman.Hugo.WebSecurity.UrlSignerTool.SignBlobs(blobIds);
foreach(string signature in signedBlobUrls) {
writer.Write("<a href="fullResource.aspx?someParam=val");
writer.Write("<img src=\"blob.aspx?");
writer.Write(signature);
writer.Write("\" /></a>");
}
}
//
// The Blob.ASPX code. Validates the signature, and if it's valid, returns the blob.
// References to Database must be changed, and you should return someimage
// describing the error when the signature is legimitally invalid.
//
public class Blob : System.Web.UI.Page
{
private void Page_Load(object sender, System.EventArgs e)
{
try
{
string data = Request["data"];
string signature = Request["sig"];
long expiryInTicks;
Guid blobId = UrlSignerTool.EnforceBlobSignature(data,
signature, out expiryInTicks);
string blobFilename = Database.BlobFileName(blobId);
Response.Clear();
Response.ContentType = "image/jpeg"; //TODO!
Response.ExpiresAbsolute = new DateTime(expiryInTicks);
Response.Flush();
Response.WriteFile(blobFilename);
Response.End();
return;
}
catch(SessionKeyCreatedException err)
{
throw err;
//TODO! return some descriptive image.
}
catch(SignatureExpiredException err)
{
throw err;
//TODO! return some descriptive image.
}
catch(SignatureInvalidException err)
{
Log.LogSecurity(3, "Potentially tampered blob signature", err);
//Don't throw. The exception is already logged.
}
}
}
using System;
using System.Security.Cryptography;
using System.IO;
using System.Text;
namespace Hallman.Hugo.WebSecurity
{
class UrlSignerTool
{
private static RandomNumberGenerator rnd = new RNGCryptoServiceProvider();
private static bool ArrayEquality(byte[] left, byte[] right)
{
if(left.Length != right.Length)
return false;
for(int i=0;i<left.Length;i++)
if(left[i] != right[i])
return false;
return true;
}
/// <summary>
/// Puts a cryptographically strong random key
/// in the session collection if it
/// is not already there.
/// </summary>
/// <returns>The key as a byte array.</returns>
private static byte[] EnsureSessionSecret(bool throwIfNotExists)
{
lock(System.Web.HttpContext.Current.Session.SyncRoot)
{
const string hashkey = "secretKey";
object key = System.Web.HttpContext.Current.Session[hashkey];
if(key != null)
return (byte[])key;
lock(rnd)
{
if(throwIfNotExists)
{
throw new SessionKeyCreatedException();
}
byte[] k = new byte[16];
rnd.GetBytes(k);
System.Web.HttpContext.Current.Session[hashkey] = k;
return k;
}
}
}
private static KeyedHashAlgorithm GetSessionKeyedHasher()
{
return GetSessionKeyedHasher(false);
}
private static KeyedHashAlgorithm
GetSessionKeyedHasher(bool throwIfNotExists)
{
HMACSHA1 s = new HMACSHA1(EnsureSessionSecret(throwIfNotExists));
return s;
}
private const int blobSignatureMaxValidMinutes = 30;
private const int blobSignatureMinValidMinutes = 5;
public static string[] SignBlobs(Guid[] blobIds)
{
KeyedHashAlgorithm signer = GetSessionKeyedHasher();
string[] blobUrls = new string[blobIds.Length];
//We could use (int) Environment.Ticks to save some cycles,
//but that's local to the machine.
long ticks = DateTime.Now.Ticks;
//Chop the least significant part off
//by max blobSignatureMaxValidMinutes.
//This is so that the url is the same for a while
//so the content can be cached.
ticks = ticks - ticks % (TimeSpan.TicksPerMinute *
blobSignatureMaxValidMinutes);
//Add a few minutes so that the client at least has time
//to fetch the data at least once.
ticks += TimeSpan.TicksPerMinute*(blobSignatureMinValidMinutes+
blobSignatureMaxValidMinutes);
for(int i=0;i<blobIds.Length;i++)
{
if(Guid.Empty == blobIds[i])
continue;
byte[] id = blobIds[i].ToByteArray();
byte[] data = new byte[16 + 8]; //8=sizeof(long)
Array.Copy(id, 0, data, 0, id.Length);
Array.Copy(BitConverter.GetBytes(ticks), 0, data, 16, 8);
id=null;
byte[] signature = signer.ComputeHash(data);
blobUrls[i] = string.Concat("data=",
System.Web.HttpUtility.UrlEncode(Convert.ToBase64String(data)),
"&sig=",
System.Web.HttpUtility.UrlEncode(Convert.ToBase64String(signature))
);
}
return blobUrls;
}
public static Guid EnforceBlobSignature(string blobToken,
string signaturetoken, out long expiryInTicks)
{
try
{
// throw if we need to generate the key,
// since there's no way we could verify the
// signature if that is the case.
KeyedHashAlgorithm signer = GetSessionKeyedHasher(true);
byte[] data = Convert.FromBase64String(blobToken);
byte[] signature =
Convert.FromBase64String(signaturetoken);
if(!ArrayEquality(signature, signer.ComputeHash(data)))
throw newSignatureInvalidException(
"blobToken="+blobToken+",signature="+signature);
long ticks = BitConverter.ToInt64(data, 16);
if(ticks < DateTime.Now.Ticks)
throw new SignatureExpiredException();
byte[] id = new byte[16];
Array.Copy(data, 0, id, 0, 16);
Guid blobId = new Guid(id);
expiryInTicks =
ticks; return
blobId;
}
catch(WebSecurityException)
{
throw;
} catch(Exception exc)
{
throw newSignatureInvalidException("blobToken="+
blobToken+",signature="+signaturetoken, exc);
}
}
}
[Serializable]
public class WebSecurityException : Exception
{
public WebSecurityException(string message, Exception innerException)
:base(message, innerException)
{
}
public WebSecurityException(string message)
:base(message)
{
}
public WebSecurityException()
:base()
{
}
}
[Serializable]
internal class SessionKeyCreatedException : WebSecurityException
{
public SessionKeyCreatedException() {}
}
[Serializable]
internal class SignatureExpiredException : WebSecurityException
{
public SignatureExpiredException() {}
}
[Serializable]
internal class SignatureInvalidException : WebSecurityException
{
public SignatureInvalidException(string additionalData,
Exception innerException)
:base("additional data: "+additionalData, innerException)
{
}
public SignatureInvalidException(string additionalData)
:base("additional data: "+additionalData)
{
}
public SignatureInvalidException() {}
}
}
性能
当然,你可以选择任何你认为合适的哈希算法,但我的经验测试表明,System.Security.Cryptography
命名空间中的所有哈希算法(除了MACTripleDES
)都非常快。在我的系统中,它们比数据库查找快17倍(TripleDES
只快2倍!)而且,由于数据库服务器的CPU时间通常比Web服务器更宝贵,即使它不更快,我们也能获益。此外,这个测试是不公平的,因为数据库拥有远远更好的机会来缓存我们需要的内容而没有其他内容,这在现实中不会发生。我实际上预计在生产环境中的比较会提高一到五百倍。
另外,你可能只想使用一个静态存储的Hash对象,而不是使用session密钥作为hash对象,而是将密钥附加到要哈希的数据中。它不提供相同的加密强度,但对于大多数应用程序来说应该足够了。(理论相当复杂。)
历史
这是我第一篇CodeProject文章的第一个版本。