1
build / build-push (push) Successful in 1m15s

This commit is contained in:
wanghu
2026-09-04 10:38:41 +08:00
parent 9fe45c810e
commit 2ea71d90e1
2 changed files with 107 additions and 28 deletions
@@ -45,7 +45,8 @@ namespace YhCore.WebApi.Auth
/// <summary>
/// 构造 service 回调地址:{root}{SignInCallbackPath}[?returnUrl=&lt;urlencoded(returnUrl)&gt;]。
/// 回跳地址只允许本地相对路径(/xxx),杜绝开放重定向。
/// 回跳地址允许两种:本地相对路径(/xxx),或与 service 根 host 同源的完整 http(s) URL
/// (前端页完整地址,yschreferer 携带,含 #/ 路由片段——对齐 Java notice 语义),杜绝开放重定向。
/// 注意:回跳地址走 query 而非路径矩阵参数——ASP.NET Core 不识别矩阵参数,分号会破坏
/// 路由匹配与 StartsWithSegments 的段比较(Java Spring 因 UrlPathHelper 默认
/// removeSemicolonContent=true 剥离分号内容才可用矩阵参数,.NET 无此机制)。
@@ -53,7 +54,7 @@ namespace YhCore.WebApi.Auth
private string BuildServiceUrl(string returnUrl)
{
var callback = BuildCallbackRoot() + Options.SignInCallbackPath;
if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl))
if (string.IsNullOrEmpty(returnUrl) || !IsAllowedReturnTarget(returnUrl, AllowedHost()))
{
return callback;
}
@@ -75,7 +76,7 @@ namespace YhCore.WebApi.Auth
return query.Count > 0 ? service + "?" + string.Join("&", query) : service;
}
/// <summary>取回调 query 中的 returnUrl(仅本地相对路径,非法返回 null)。</summary>
/// <summary>取回调 query 中的 returnUrl(本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary>
private string ResolveReturnUrl()
{
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
@@ -83,7 +84,7 @@ namespace YhCore.WebApi.Auth
return null;
}
var url = value.ToString();
return IsLocalUrl(url) ? url : null;
return IsAllowedReturnTarget(url, AllowedHost()) ? url : null;
}
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
@@ -163,9 +164,9 @@ namespace YhCore.WebApi.Auth
Context.User = principal;
// 回跳原地址:优先 query 的 returnUrl(当前形态),兼容旧 ;redirectToReferer=<hex> 回调。
// 仅限本地相对路径,防开放重定向;非法回根路径
// 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。
var referer = ResolveReturnUrl() ?? ParseRedirectToReferer(Context.Request.Path.Value);
var redirect = referer != null && IsLocalUrl(referer) ? referer : "/";
var redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/";
Response.Redirect(redirect);
return AuthenticateResult.NoResult();
}
@@ -185,47 +186,78 @@ namespace YhCore.WebApi.Auth
return HexDecode(path.Substring(marker + RedirectToRefererMarker.Length));
}
/// <summary>UTF-8 字节 → 小写十六进制(对齐平台旧版登录 URL 的 redirectToReferer 编码)。</summary>
/// <summary>字符串 → 小写十六进制(char 维度,与前端 strToHexCharCode / Java HexUtils.stringToHex 一致:
/// 逐字符取码点转两位 hex;ASCII 范围内结果与 UTF-8 字节编码相同)。</summary>
private static string HexEncode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes)
var sb = new StringBuilder();
foreach (var ch in value ?? string.Empty)
{
sb.Append(b.ToString("x2"));
sb.Append(((int)ch).ToString("x2"));
}
return sb.ToString();
}
/// <summary>小写/大写十六进制 → UTF-8 字符串;长度为奇数或含非十六进制字符时返回 null。</summary>
/// <summary>小写/大写十六进制 → 字符串(char 维度解码,对齐前端 yschreferer / Java hexToString);
/// 长度为奇数或含非十六进制字符时返回 null。</summary>
private static string HexDecode(string hex)
{
if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0)
{
return null;
}
var bytes = new byte[hex.Length / 2];
for (var i = 0; i < bytes.Length; i++)
var sb = new StringBuilder(hex.Length / 2);
for (var i = 0; i < hex.Length; i += 2)
{
if (!byte.TryParse(hex.Substring(i * 2, 2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out var b))
if (!int.TryParse(hex.Substring(i, 2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out var code))
{
return null;
}
bytes[i] = b;
sb.Append((char)code);
}
return Encoding.UTF8.GetString(bytes);
return sb.ToString();
}
/// <summary>仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。</summary>
private static bool IsLocalUrl(string url)
/// <summary>
/// 判定回跳目标是否允许:
/// 1. 本地相对路径(/xxx 开头):拒绝协议相对地址(//host)与反斜杠变体(/\…)——原有安全口径;
/// 2. 完整 http(s) URL:host 必须与 service 根 host(AppBaseUrl 或当前请求)一致——供前端页完整
/// 地址(yschreferer 携带,含 #/ 路由片段)作回跳目标,杜绝开放重定向到任意外站。
/// 其余情况(null/空、非 http(s) 协议、外站 host)一律拒绝。
/// </summary>
private static bool IsAllowedReturnTarget(string url, string allowedHost)
{
if (string.IsNullOrEmpty(url) || url[0] != '/')
if (string.IsNullOrEmpty(url))
{
return false;
}
return !url.StartsWith("//", StringComparison.Ordinal)
&& !url.StartsWith("/\\", StringComparison.Ordinal);
if (url[0] == '/')
{
return !url.StartsWith("//", StringComparison.Ordinal)
&& !url.StartsWith("/\\", StringComparison.Ordinal);
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return false;
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
return false;
}
return string.IsNullOrEmpty(allowedHost)
|| string.Equals(uri.Host, allowedHost, StringComparison.OrdinalIgnoreCase);
}
/// <summary>service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。</summary>
private string AllowedHost()
{
var root = Options.AppBaseUrl;
if (string.IsNullOrEmpty(root))
{
root = Context.Request.Scheme + "://" + Context.Request.Host.Value;
}
return Uri.TryCreate(root, UriKind.Absolute, out var uri) ? uri.Host : null;
}
}
}