我最近使用 .NET Core 2.2 造了个名为"Link Forwarder" (链接转发器)的 URL 转发服务,并已开源。目前预览版已部署到我的子域"go.edi.wang"。本文将分享我如何构建这个项目,以及我学到的东西。
为了帮助大家了解系统并浏览代码,请查看我的 GitHub 存储库:https://github.com/EdiWang/LinkForwarder
面向的问题
互联网上的资源有时会更改其 URL。例如,当我 10 年前创建网站时,一个典型的博客文章 URL 就像"https://myolddomain.net/viewarticle.aspx?id=123"。我朋友在其他网站的帖子上引用了这个URL,或讲它发给其他人。几年后,我拥有了一个新域名,并推出了一个新的博客系统,完全改变了该文章的URL,例如"https://edi.wang/post/2009/1/1/an-old-article",这使得任何旧的URL引用都失效。还好我的博客不盈利,所以没太大关系。
但是,这个问题可能发生在企业的产品上。尤其是对于客户端系统和应用程序。比如将产品的支持链接写入安装在客户端的产品中,结果有一天该链接更改了,那么您就必须将所有客户端推送更新。
为了解决这个问题,我想以微软为榜样。微软创建了"go.microsoft.com",它使用不会更改的静态 ID,以重定向到可能随时间变化的实际 URL。例如,https://go.microsoft.com/fwlink/?linkid=2049807 指向的是基于Chromium 的 Edge 浏览器的帮助文档,该文档目前 URL 是 https://microsoftedgesupport.microsoft.com/hc/en-us 。如果文档的 URL 随时间而变化,Edge 浏览器不必更改其内置帮助链接。微软只需要更新其数据库以更改链接 ID 2049807 的目标 URL。这种"go.microsoft.com"服务在微软产品中随处可见。
这是链接转发器的基本思想。
基本流程
管理员为有效的 URL (例如https://www.some-website.com/1234/abcd/1.html) 创建Token URL(例如https://go.edi.wang/fw/e66fad1e)。然后,用户可以使用生成的Token URL 重定向到原始 URL。每次成功重定向都将偷偷记录用户的浏览器 UA 和 IP 地址,以便管理员可以查看报表并暗中观察一切(得加个隐私协议)。
报表页面
创建/编辑链接
分享链接
并非短链接服务
链接转发器非常像,但并不是短链接。关键差异在于:
短链接的目标是创建尽可能短的 URL,通常部署到非常短的域名。链接转发器并不关心是否将其部署到长域名。
大多数短链接服务不允许在创建链接后再修改。但是链接转发器的目标是面向更改。
并不简单
链接转发器不只是将Token映射到 URL。需要考虑以下问题。
它需要足够快,并能处理一定量的流量
我当前的设计会缓存有效的 URL 重定向,因此对于对同一令牌的请求,系统不会每次都查询数据库。
如何处理无效的令牌或有效但不存在的 URL?
对于无效令牌,停止请求。对于该有效的令牌,但它指向不存在的 URL(数据库中没有记录),将用户重定向到预先设置的默认 URL。
系统需要保护用户免受潜在有害链接的侵害
例如,链接转发器的数据库遭到破坏,并且 URL 指向"https://127.0.0.1/some-virus",可以触发一个事先安装在本地的病毒。用户就可能会受到攻击。其他 URL (如"/abc"、"123") 也被视为无效 URL,不会执行重定向。
对于可能包含恶意代码的互联网 URL,目前不在设计范围中。但是,也许将来我们可以集成第三方服务来识别链接。
系统需要自我保护
指向系统本身的链接可能会导致重定向死循环并把服务器爆上天。
例如:
https://go.edi.wang/fw/a 指向 https://go.edi.wang/fw/b
https://go.edi.wang/fw/b 又指向 https://go.edi.wang/fw/a
如果将链接转发器或其他类似的系统部署到另一个域,也会发生类似的情况。甚至可以有多个节点参与在循环中:
尽管现代浏览器会停止这种重定向循环,但攻击者可以通过不使用现代浏览器或根本不使用浏览器来绕过此限制。
对于指向服务器域本身的链接,我们可以轻松地识别和阻止它。但对于有多放参与的重定向环,我找不到识别和阻止请求的可靠方法。因此,我只能绕弯解决,将特定时间段内同一 IP 地址的同一令牌的请求数做限制,本文稍后将对此进行说明。
重定向流程
下图说明了URL重定向流程。(手机上看不清可以稍后查看原文)
数据库设计
我们只需要两张表就能进行重定向和跟踪用户事件。我选择的数据库引擎是用于开发的 LocalDB 和用于生产的 Microsoft Azure SQL Database。
SQL脚本:
IF NOT EXISTS(SELECT TOP 1 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = N'Link')
CREATE TABLE [Link](
[Id] [int] IDENTITY(1,1) PRIMARY KEY NOT NULL,
[OriginUrl] [nvarchar](256) NULL,
[FwToken] [varchar](32) NULL,
[Note] [nvarchar](max) NULL,
[IsEnabled] [bit] NOT NULL,
[UpdateTimeUtc] [datetime] NOT NULL)
IF NOT EXISTS(SELECT TOP 1 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = N'LinkTracking')
CREATE TABLE [LinkTracking](
[Id] UNIQUEIDENTIFIER PRIMARY KEY NOT NULL,
[LinkId] [int] NOT NULL,
[UserAgent] [nvarchar](256) NULL,
[IpAddress] [varchar](64) NULL,
[RequestTimeUtc] [datetime] NOT NULL)
IF NOT EXISTS(SELECT TOP 1 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_NAME = N'FK_LinkTracking_Link')
ALTER TABLE [LinkTracking] WITH CHECK ADD CONSTRAINT [FK_LinkTracking_Link] FOREIGN KEY([LinkId])
REFERENCES [Link] ([Id])
ON UPDATE CASCADE
ON DELETE CASCADE
ALTER TABLE [LinkTracking] CHECK CONSTRAINT [FK_LinkTracking_Link]
ASP.NET Core 应用程序设计
为了避免篇幅又臭又长,本文不列出代码的每处细节。完整参考请查看项目 GitHub 仓库:https://github.com/EdiWang/LinkForwarder
LinkForwarder.Web
ASP.NET Core MVC 应用程序作为入口点。它控制 URL 重定向、链接验证、本地帐户或 Azure AD 的身份验证、创建或编辑链接以及查看报告。
LinkForwarder.Services
定义对数据库的 CRUD 操作,并通过 ILinkForwarderService 接口和实现 LinkForwarderService 获取报告数据。稍后解释的 ITokenGenerator 也在此项目中。
LinkForwarder.Setup
用于运行 SQL 脚本以为新服务器设置数据库。这仅在系统的第一次运行中使用。
关键点
Token生成
"/fw"后面的参数是一个 Token。它用于在数据库中查找源 URL。我不使用 Link.Id 的原因是,当执行数据库迁移或从多个服务器合并数据库时,Id 可能会更改。但Token将保持不变。
系统使用 ITokenGenerator 接口生成Token。
public interface ITokenGenerator
{
string GenerateToken();
bool TryParseToken(string input, out string token);
}
GenerateToken() 用于在提交新 URL 时创建新Token。
TryParseToken() 用于验证客户端请求的Token格式。
目前,ITokenGenerator 接口的唯一实现是ShortGuidTokenGenerator。它将以 GUID 的前 8 个字符作为Token。
public class ShortGuidTokenGenerator : ITokenGenerator
{
private const int Length = 8;
public string GenerateToken()
{
return Guid.NewGuid().ToString().Substring(0, Length).ToLower();
}
public bool TryParseToken(string input, out string token)
{
token = null;
if (input.Length != Length)
{
return false;
}
token = input;
return true;
}
}
注意:在此示例中,TryParseToken() 并不总是可靠的,因为无法判断 8 个字符的字符串是否属于 GUID。您当然可以根据自己的规则创建另一个Token生成器,这些规则可以进行准确的Token验证。
创建新链接
首先,我们需要防止为已经存在的 URL 创建新Token。对于现有 URL,我们可以查找旧记录并返回旧Token,而不是生成新Token。在此之前,我们还需要再次验证现有URL的Token,以确保数据良好。例如,黑客可以将数据库中的Token更改为某个恶意字符串,我不希望它最终追加到 URL 上。
所以,TryParseToken() 必须比我目前的设计更可靠。
其次,我们需要防止生成已存在的令牌。完整 GUID 是可靠的,但部分 GUID 不是。
基于这两个因素,创建新链接的代码将是:
const string sqlLinkExist = "SELECT TOP 1 FwToken FROM Link l WHERE l.OriginUrl = @originUrl";
var tempToken = await conn.ExecuteScalarAsync
if (null != tempToken)
{
if (_tokenGenerator.TryParseToken(tempToken, out var tk))
{
_logger.LogInformation($"Link already exists for token '{tk}'");
return new SuccessResponse
}
string message = $"Invalid token '{tempToken}' found for existing url '{originUrl}'";
_logger.LogError(message);
}
const string sqlTokenExist = "SELECT TOP 1 1 FROM Link l WHERE l.FwToken = @token";
string token;
do
{
token = _tokenGenerator.GenerateToken();
} while (await conn.ExecuteScalarAsync
_logger.LogInformation($"Generated Token '{token}' for url '{originUrl}'");
var link = new Link
{
FwToken = token,
IsEnabled = isEnabled,
Note = note,
OriginUrl = originUrl,
UpdateTimeUtc = DateTime.UtcNow
};
const string sqlInsertLk = @"INSERT INTO Link (OriginUrl, FwToken, Note, IsEnabled, UpdateTimeUtc)
VALUES (@OriginUrl, @FwToken, @Note, @IsEnabled, @UpdateTimeUtc)";
await conn.ExecuteAsync(sqlInsertLk, link);
return new SuccessResponse
验证重定向 URL
系统使用 ILinkVerifier 接口在将其发送到链接到客户端之前验证 URL。有 3 种无效状态:
无效格式: 例如"865c8gyiB"
本地 URL: 例如"/some-path"
自引用 URL: 例如"https://go.edi.wang/some-path"
public enum LinkVerifyResult
{
Valid,
InvalidFormat,
InvalidLocal,
InvalidSelfReference
}
public interface ILinkVerifier
{
LinkVerifyResult Verify(string url, IUrlHelper urlHelper, HttpRequest currentRequest);
}
我们可以利用ASP.NET MVC 的 IUrlHelper 接口执行前两个无效情况的验证。
public LinkVerifyResult Verify(string url, IUrlHelper urlHelper, HttpRequest currentRequest)
{
if (!url.IsValidUrl())
{
return LinkVerifyResult.InvalidFormat;
}
if (urlHelper.IsLocalUrl(url))
{
return LinkVerifyResult.InvalidLocal;
}
if (Uri.TryCreate(url, UriKind.Absolute, out var testUri))
{
if (string.Compare(testUri.Authority, currentRequest.Host.ToString(), StringComparison.OrdinalIgnoreCase) == 0
&& string.Compare(testUri.Scheme, currentRequest.Scheme, StringComparison.OrdinalIgnoreCase) == 0
&& testUri.AbsolutePath != "/")
{
return LinkVerifyResult.InvalidSelfReference;
}
}
return LinkVerifyResult.Valid;
}
要检查 URL 是否采用有效格式:
public enum UrlScheme
{
Http,
Https,
All
}
public static bool IsValidUrl(this string url, UrlScheme urlScheme = UrlScheme.All)
{
bool isValidUrl = Uri.TryCreate(url, UriKind.Absolute, out var uriResult);
if (!isValidUrl)
{
return false;
}
switch (urlScheme)
{
case UrlScheme.All:
isValidUrl &= uriResult.Scheme == Uri.UriSchemeHttps || uriResult.Scheme == Uri.UriSchemeHttp;
break;
case UrlScheme.Https:
isValidUrl &= uriResult.Scheme == Uri.UriSchemeHttps;
break;
case UrlScheme.Http:
isValidUrl &= uriResult.Scheme == Uri.UriSchemeHttp;
break;
}
return isValidUrl;
}
IP 请求速率限制
对于单个 IP,重定向入口 (/fw/{token} ) 在一分钟内最多包含 30 个请求。
[Route("/fw/{token}")]
public async Task
appsettings.json中的配置控制 IP 限制规则:
"IpRateLimiting": {
"EnableEndpointRateLimiting": true,
"StackBlockedRequests": false,
"RealIpHeader": "X-Real-IP",
"ClientIdHeader": "X-ClientId",
"HttpStatusCode": 429,
"GeneralRules": [
{
"Endpoint": "*:/fw/*",
"Period": "1m",
"Limit": 30
}
]
}
有关如何进行 IP 速率限制的更完整介绍,请查看我之前的博客文章《IP Rate Limit for ASP.NET Core》 https://edi.wang/post/2019/6/16/ip-rate-limit-for-aspnet-core
从User Agent里暗中观察
典型的 User Agent 字符串如下:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.12 Safari/537.36 Edg/76.0.182.6
为了最方便地从中获取信息,我使用一个名为 UAParser 的库。(有了轮子就别自己造,.NET程序员不需要福报)
var uaParser = Parser.GetDefault();
string GetClientTypeName(string userAgent)
{
ClientInfo c = uaParser.Parse(userAgent);
return $"{c.OS.Family}-{c.UA.Family}";
}
此代码允许我按 操作系统-浏览器 对数据进行分组。例如,Windows 7 + Chrome 60 的用户和 Windows 10 + Chrome 62 的用户都将分组为 Windows-Chrome。因此,最终的饼图不会显示太多碎片序列。
var q = from d in userAgentCounts
group d by GetClientTypeName(d.UserAgent)
into g
select new ClientTypeCount
{
ClientTypeName = g.Key,
Count = g.Sum(gp => gp.RequestCount)
};
还没完事
链接转发器项目处于早期阶段。我能想到很多改进和新功能。例如为第三方提供 REST API、为管理链接添加Tag、甚至在ASP.NET Core 3.0 发布后使用 Blazor。技术上也存在可以优化的地方,比如是否需要引入HASH查找、LinkTracking表到底用不用GUID主键、索引怎么加等等,类似这些需要经过一段时间的线上实践才能做决定。这是一个开源项目,所以我欢迎大家一起帮它变得更牛逼!