From e9c4b6424061d15ff4ccf8a3c2929f3a68dabb9d Mon Sep 17 00:00:00 2001 From: wanghu Date: Fri, 4 Sep 2026 12:52:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9F=A9=E9=98=B5=E5=88=86=E5=8F=B7=E6=AE=B5?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E5=A4=84=E7=90=86=EF=BC=88=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=20Java=20removeSemicolonContent=20+=20notice=20=E5=9B=9E?= =?UTF-8?q?=E8=B7=B3=E8=AF=AD=E4=B9=89=EF=BC=89=EF=BC=9AUseRouting=20?= =?UTF-8?q?=E5=89=8D=E5=89=A5=E7=A6=BB=E5=88=86=E5=8F=B7=E6=AE=B5=E6=9D=9C?= =?UTF-8?q?=E7=BB=9D=20404=EF=BC=9B;redirectToReferer=3D=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=B9=B6=20302=20=E8=A7=A3=E7=A0=81=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=EF=BC=9B=E5=B8=A6=20ticket=20=E7=99=BB=E5=BD=95=E5=9B=9E?= =?UTF-8?q?=E8=B7=B3=E6=94=BE=E8=A1=8C=E8=AE=A4=E8=AF=81=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Middleware/PathMatrixSegmentMiddleware.cs | 138 ++++++++++++++++++ src/YhCore.WebApi/Startup.cs | 5 + 2 files changed, 143 insertions(+) create mode 100644 src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs diff --git a/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs b/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs new file mode 100644 index 0000000..9e72710 --- /dev/null +++ b/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs @@ -0,0 +1,138 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using System; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; + +namespace YhCore.WebApi.Middleware +{ + /// + /// 路径矩阵分号段前置中间件(须注册在 UseRouting 之前)。 + /// 对齐 Java notice 行为:Spring 路由前默认 UrlPathHelper.removeSemicolonContent=true 会剥离 + /// 分号内容(矩阵参数),ASP.NET Core 不做——带 ';' 的路径进入 UseRouting 会匹配不到任何 action + /// 而 404。本中间件在路由前模拟该语义: + /// - 路径含 ;redirectToReferer=<hex>(service 分号矩阵段,CAS 登录引导时写入):按 notice + /// 语义解析 hex 并 302 到解码地址——覆盖"已登录刷新/直访带分号 URL"等 handler 未接管的场景, + /// 不再 404;hex 解码失败/目标非法则剥离分号段继续(杜绝开放重定向)。 + /// - 其余含 ';' 的路径:剥离首个 ';' 起的分号段后继续,正常路由/保护判定不再 404。 + /// 例外:请求带 ticket(CAS 登录回跳在途)→ 原样放行,不在此 302/剥离——须先由认证链路校验 ticket、 + /// 写入登录 Cookie(成功后由 CasAuthenticationHandler 302 + SsoProtectionMiddleware 短路); + /// 剥离会破坏 serviceValidate 重建一致性,提前 302 会跳过 Cookie 建立导致死循环。 + /// + public class PathMatrixSegmentMiddleware + { + private const string RedirectToRefererMarker = ";redirectToReferer="; + + private readonly RequestDelegate _next; + private readonly IConfiguration _configuration; + + public PathMatrixSegmentMiddleware(RequestDelegate next, IConfiguration configuration) + { + _next = next; + _configuration = configuration; + } + + public async Task InvokeAsync(HttpContext context) + { + var rawPath = context.Request.Path.Value ?? string.Empty; + var semi = rawPath.IndexOf(';'); + if (semi < 0) + { + await _next(context); + return; + } + + // CAS 登录回跳在途(service 含分号段 + ticket):原样放行给认证链路。 + if (context.Request.Query.ContainsKey("ticket")) + { + await _next(context); + return; + } + + var marker = rawPath.IndexOf(RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase); + if (marker >= 0) + { + var hex = rawPath.Substring(marker + RedirectToRefererMarker.Length); + var term = hex.IndexOfAny(new[] { ';', '?', '#' }); + if (term >= 0) + { + hex = hex.Substring(0, term); + } + var target = HexToString(hex); + if (!string.IsNullOrEmpty(target) && IsAllowedMatrixTarget(target, _configuration["yh:sso:AppBaseUrl"])) + { + context.Response.Redirect(target); + return; + } + // 解码失败/非法目标:不跳转(防开放重定向),落入下方剥离分号段走正常流程。 + } + + context.Request.Path = new PathString(rawPath.Substring(0, semi)); + await _next(context); + } + + /// + /// 矩阵分号段回跳目标合法性(与 CasAuthenticationHandler.IsAllowedReturnTarget 同口径): + /// 拒绝含控制字符目标;本地相对路径(拒绝协议相对 // 与反斜杠变体);完整 http(s) URL 须与 + /// AppBaseUrl host 一致或为本机 loopback(localhost/127.0.0.1/::1 联调);AppBaseUrl 未配置则不做 + /// host 白名单。 + /// + private static bool IsAllowedMatrixTarget(string url, string appBaseUrl) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + foreach (var ch in url) + { + if (ch < 0x20 || ch == 0x7f) + { + return false; + } + } + 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; + } + string allowedHost = null; + if (!string.IsNullOrWhiteSpace(appBaseUrl) + && Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var baseUri)) + { + allowedHost = baseUri.Host; + } + return string.IsNullOrEmpty(allowedHost) + || string.Equals(uri.Host, allowedHost, StringComparison.OrdinalIgnoreCase) + || uri.IsLoopback; + } + + /// 十六进制 → 字符串(char 维度,每两位还原一个字符;奇数长/非法 hex 返回 null)。 + private static string HexToString(string hex) + { + if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0) + { + return null; + } + var sb = new StringBuilder(hex.Length / 2); + for (var i = 0; i < hex.Length; i += 2) + { + if (!int.TryParse(hex.Substring(i, 2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var code)) + { + return null; + } + sb.Append((char)code); + } + return sb.ToString(); + } + } +} diff --git a/src/YhCore.WebApi/Startup.cs b/src/YhCore.WebApi/Startup.cs index 6697e65..4ceb4db 100644 --- a/src/YhCore.WebApi/Startup.cs +++ b/src/YhCore.WebApi/Startup.cs @@ -210,6 +210,11 @@ namespace YhCore.WebApi // 放在 UseRouting 之前:静态文件请求直接短路,不进入 SSO/认证管道。 app.UseStaticFiles(); + // 路径矩阵分号段前置处理(;redirectToReferer= 解析回跳 + 分号段剥离对齐 Spring + // removeSemicolonContent)。必须在 UseRouting 之前:路由按含分号路径匹配不到 action 会 404, + // 且此阶段改 Request.Path 才来得及影响路由选择。带 ticket 的登录回跳原样放行给认证链路。 + app.UseMiddleware(); + // 管道顺序:先路由,再认证;CAS 回调必须在认证阶段消费 ticket 并写入 Cookie。 app.UseRouting();