ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

.NET WebForm一般处理程序跨域处理:CORS方案与BaseHandler基类实践

.NET WebForm一般处理程序跨域处理:CORS方案与BaseHandler基类实践 这阵子帮朋友瞄了一个老项目——典型的.NET WebForm全家桶后端用.ashx一般处理程序暴露接口前端却是另一套独立部署的前后端分离系统。两边一联调浏览器立刻给我脸色看控制台弹出一行熟悉的英文报错Access to XMLHttpRequest at xxx from origin xxx has been blocked by CORS policy。说实话看到“一般处理程序跨域问题”这几个词我的第一反应是又一个前端页面跨域调ashx的老场景。这个问题在WebForm老项目里太常见了尤其是维护了七八年的系统新需求基本都要让老接口被新页面跨域调用。这篇东西我就围绕“.NET WebForm一般处理程序跨域问题处理”展开把自己踩过坑、实际验证过的方案完整写出来。它不仅适用ashx也适用WebServiceasmx和部分WebAPI旧接口。适合正在维护老项目、需要让旧接口支撑新前端的开发同学也适合刚接触WebForm、被“跨域”这个词绕晕的新手。我尽量把原理讲明白把能直接抄的代码给全。1. 问题本源与解决思路1.1 场景还原为什么一般处理程序会撞上跨域先说背景。WebForm时代一个很常规的做法是建一个Handler.ashx在ProcessRequest里用context.Response.Write吐出一段JSON前端用jQuery的$.ajax或$.getJSON直接请求。页面和后端API同源跑得顺顺当当。问题是现在很多公司搞前后端分离前端页面跑到另一个域名、另一个端口的静态资源服务器上浏览器一检查发现“协议、域名、端口”三个条件里至少有一个不一致请求返回的数据就直接被拦截。这里有个很重要的认知跨域报错出现时服务器其实早就处理完请求了数据甚至已经返回。拦截发生在浏览器的安全机制层面不是服务器不给响应。同源策略是浏览器的内置沙箱目的就是限制不同源的脚本互相读取数据。所以问题本质是你的ashx接口本身没问题缺的是一套浏览器认可的跨域授权协议。老项目还有一个特点接口没有统一的入口封装每个ashx各写各的。这就导致很多人遇到跨域时第一反应是“给每个接口都加响应头”改了一下午累死。后面我会讲怎么用一个基类把这件事统一掉。1.2 三种方案选型CORS、JSONP和反向代理处理跨域不是只有一条路。针对WebForm一般处理程序业界常见的无非是三套打法CORS、JSONP、反向代理。先做个对比你心里有个数。方案实现难度是否改后端支持请求方式适用场景CORS服务端加响应头低纯后端配置必须改GET、POST、PUT、DELETE均可绝大多数场景推荐首选JSONP动态script标签中需改接口返回格式必须改仅GET老系统里只能GET、且前端急等着联调的场景反向代理Nginx/IIIS转发中需运维配合无需改全部不想动老代码、域名能统一管理的场景CORS是HTML5标准方案现在基本属于跨域处理的默认答案。WebForm里用CORS的改动成本是最低的在响应头里补几个Access-Control-Allow-*字段即可不需要改业务逻辑。JSONP虽然老但只支持GET遇到POST直接没戏。反向代理要运维帮忙有时候为了跨域专门加一层代理反而把事情搞复杂了。我在实操时一般默认先上CORS只有当接口是古老的外部开放接口、无法改后端响应头时才会建议前端用JSONP顶一下。1.3 先搞清楚CORS到底是谁在检查有新手问我“跨域是不是服务器拒绝了”不是。我刚才说过真正检查的是浏览器。但浏览器怎么知道服务器允不允许跨域靠的就是响应头。当浏览器的跨域请求回来后它会看响应里有没有Access-Control-Allow-Origin值和自己当前页面的源是否匹配。如果匹配就放行如果不匹配数据直接“吞掉”抛给控制台一个CORS错误。所以后端要做的事情很纯粹在HTTP响应中把跨域许可的头字段加上。浏览器不关心你的ashx是通过context.Response.Write输出还是通过return输出只要头部信息到了它就认为服务器显式同意了这个请求。这个思想理顺之后剩下的全部是技术细节。接下来我要花点篇幅把CORS的几个响应头拆开讲因为很多人包括我早期只知道加一个Access-Control-Allow-Origin: *后面遇到Cookie、PUT请求照样报错就是因为没弄懂整套机制。2. CORS核心机制不是加一个头就完事2.1 简单请求与预检请求OPTIONS为什么这么重要CORS把请求分成两类简单请求和预检请求。简单请求一般指GET、HEAD、POST且Content-Type为application/x-www-form-urlencoded、multipart/form-data或text/plain这类基本条件的请求。简单请求不发预检浏览器直接带着跨域源发请求服务器返回时给对头就放行。但如果你用application/json发POST或者用了PUT、DELETE或者带了自定义头触发器判定这就是“非简单请求”浏览器会先发出一个OPTIONS请求这个请求叫“预检请求”预检查通之后真正的业务请求才会发送。WebForm老项目最容易翻车的地方就在这你给ashx加了Access-Control-Allow-Origin前端一调用还是报CORS看网络请求又多了一个状态码405的OPTIONS。原因就是OPTIONS请求到达服务器后你的ashx压根没有处理OPTIONS的逻辑IIS默认给405 Method Not Allowed。预检失败后面的请求直接被浏览器中止。处理方式有两种。一种是在全局层面Application_BeginRequest拦截OPTIONS请求直接返回200另一种是在ashx的ProcessRequest里判断context.Request.HttpMethod是不是OPTIONS如果是就返回200和必要的响应头。我更推荐用基类统一处理后面代码部分细说。2.2 三个核心响应头的含义与配置逻辑开发中反复用到的主要是三个头字段Access-Control-Allow-Origin允许哪个源跨域访问。可以是具体的一个源如https://app.example.com也可以是通配符*。注意通配符有个大坑一旦你需要使用Cookie或认证凭证*就不能用了必须写明确认的源。Access-Control-Allow-Methods允许哪些HTTP方法。常见值是GET, POST, PUT, DELETE, OPTIONS。这个头主要影响预检阶段的判断。Access-Control-Allow-Headers允许的额外请求头。如果你的请求带了Content-Type: application/json就必须在这个字段里带上Content-Type否则预检也会失败。还有个经常一起出现的Access-Control-Allow-Credentials值只能是true。它表示是否允许浏览器携带Cookie等凭证。它一旦为trueAccess-Control-Allow-Origin就不能为*了必须写成明确的域名。这是CORS规范里的一个硬性约束浏览器会直接校验。我在给老接口加跨域时但凡发现项目里有登录态、有Session就一定把Origin回显改成动态读取请求头Origin的值而不是图省事写个*。2.3 携带Cookie和Session时为什么要回显Origin网上很多教程说直接context.Response.AddHeader(Access-Control-Allow-Origin, *)就行。这种做法在纯公开接口、无登录态场景下确实能通但一旦接口依赖Session或Cookie——比如ashx里要HttpContext.Current.Session[userId]拿登录用户——前端就必须给xhr.withCredentials true或者jQuery里设置xhrFields: { withCredentials: true }同时服务端的Access-Control-Allow-Origin不能是*而是要精确返回发起方源。为什么不能用*按规范Access-Control-Allow-Credentials: true时Access-Control-Allow-Origin若使用通配符*浏览器视为“未授权凭证访问”直接拦截。所以正确做法是读取请求头里的Origin判断是否在白名单范围内是在范围内则原样回写同时在响应里加Access-Control-Allow-Credentials: true。我见过有的项目图省事直接让前端把接口请求方式改为JSONP。JSONP天然能带Cookie、但也天然只能GET。老系统接口如果原本就是POSTJSONP基本等于重写接口。这也是我坚持推荐CORS的原因——改动集中在响应头业务代码基本不碰。3. 在一般处理程序中落地实现CORS3.1 最小改动方案直接在ProcessRequest里加响应头先给一个改动最小的方案适合接口少、只想快速验证的场景。新建一个Handler.ashx示例在ProcessRequest开头加几行public void ProcessRequest(HttpContext context) { // 允许跨域访问注意这里如果用了*则不能配合Cookie一起用 context.Response.AddHeader(Access-Control-Allow-Origin, *); context.Response.AddHeader(Access-Control-Allow-Methods, GET, POST, OPTIONS); context.Response.AddHeader(Access-Control-Allow-Headers, Content-Type, Accept); if (context.Request.HttpMethod OPTIONS) { context.Response.StatusCode 200; context.Response.End(); return; } // 这里是你原本的业务逻辑比如读取数据库、拼接JSON字符串 context.Response.ContentType application/json; context.Response.Write({\code\:0,\data\:\ok\}); }这段代码逻辑很直白所有响应都带跨域许可头如果请求是OPTIONS直接返回200业务就算有也不会执行到。context.Response.End()的作用是立即终止当前响应让预检不再往下走。这里重点强调AddHeader必须在context.Response.Write之前执行。很多人把响应头加在Write之后或者放在if判断后面结果浏览器没收到头照样被拦截。响应头本质是HTTP报文的一部分报文一旦发送完毕或者开始发送Body再修改头就来不及了。另外如果项目里统一了编码或者IIS上装了自定义模块Response.End()偶尔会抛出ThreadAbortException这是正常现象不用专门处理。有一点我踩过坑再强调一下context.Response.AddHeader(Access-Control-Allow-Origin, *)只适合纯API调用场景。一旦涉及Content-Type: application/jsonAjax默认可能触发预检所以Allow-Headers里必须包含Content-Type。不然你看到的报错永远都是Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response非常经典。3.2 封装BaseHandler基类一次性解决所有ashx最推荐的长期方案是写一个BaseHandler让现有的一般处理程序继承它子类只处理业务逻辑跨域统一在基类里完成。这样即使有几十个ashx也不用逐个改。我这里提供我实践中稳定使用的一个模板直接复制就能跑。using System; using System.Web; namespace WebApp.Handlers { /// summary /// 所有需要跨域的一般处理程序统一继承此基类 /// /summary public abstract class BaseHandler : IHttpHandler { public bool IsReusable false; public void ProcessRequest(HttpContext context) { // 1. 跨域许可预检统一处理必须在业务逻辑之前 if (!HandleCors(context)) { return; } // 2. 子类实现业务约定有一个相互传上下文的方法 Execute(context); } /// summary /// 子类实现具体业务相当于原来ProcessRequest里的核心逻辑 /// /summary protected abstract void Execute(HttpContext context); private bool HandleCors(HttpContext context) { var origin context.Request.Headers[Origin]; // 允许的来源列表这里可以写配置也可以写多个判断 var allowedOrigins new[] { https://app.example.com, https://console.example.com }; if (!string.IsNullOrEmpty(origin)) { var allowed false; foreach (var item in allowedOrigins) { if (string.Equals(item, origin, StringComparison.OrdinalIgnoreCase)) { allowed true; break; } } if (!allowed) { context.Response.StatusCode 403; context.Response.End(); return false; } // 回显具体Origin而不是用*原因就是兼容Cookie/Session context.Response.AddHeader(Access-Control-Allow-Origin, origin); context.Response.AddHeader(Access-Control-Allow-Credentials, true); } else { // 如果没有Origin例如普通服务端请求也不需要跨域许可 } context.Response.AddHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); context.Response.AddHeader(Access-Control-Allow-Headers, Content-Type, Accept, X-Requested-With); // 预检请求直接返回200不继续走业务 if (context.Request.HttpMethod OPTIONS) { context.Response.StatusCode 200; context.Response.End(); return false; } return true; } } }子类的写法public class UserHandler : BaseHandler { protected override void Execute(HttpContext context) { context.Response.ContentType application/json; charsetutf-8; context.Response.Write({\userId\:1024,\name\:\admin\}); } }继承之后前端请求/Handlers/UserHandler.ashx就自然带了跨域许可。这里我把IsReusable设为false这在有Session的ashx里很重要。如果设为true实例可能被复用一旦涉及实例字段或Session上下文很容易出莫名其妙的并发错乱。WebForm一般处理程序的默认模板给的是true但我负责地建议都不用改。白名单这个设计我认为很必要。老项目接口大多关联内部管理数据权限本来依赖Session如果在响应头放开*再加上Allow-Credentials: true规范上浏览器不允许两边都别扭如果不限制白名单任意来源回显Origin那等于谁都能跨域调用。所以白名单是在可用性和安全之间做的合理折中。3.3 Web.config与IIS层的可选配置除了在代码里加响应头WebForm项目还有一种“全局配置”的方式在Web.config的system.webServer节点里配置httpProtocol让IIS给所有请求追加跨域头。这种方式的好处是连asmx、AXD全都能带上不必写C#代码。示例configuration system.webServer httpProtocol customHeaders add nameAccess-Control-Allow-Origin value* / add nameAccess-Control-Allow-Methods valueGET,POST,PUT,DELETE,OPTIONS / add nameAccess-Control-Allow-Headers valueContent-Type, Accept, X-Requested-With / /customHeaders /httpProtocol /system.webServer /configuration你可能会问“那岂不是比写代码还省事”确实对于纯公开接口它是极快的方案。但它的缺点也很明显取值不能用动态Origin只能写*固定值Cookie场景下白搭。另外在某些IIS版本上OPTIONS预检虽然带上了头但还是会被URL路由框架拦截成405这时仍然需要代码层面的OPTIONS直接放行。所以web.config配置适合做“及格线”要不要配合代码看你的具体业务。如果服务器上还开了HTTPS且AnHSTS头注意跨域调试时浏览器可能会被强制升级链接导致刚刚还正常的接口突然变成net::ERR_CONNECTION_RESET或证书报错。这跟跨域其实是两回事但有人会误判。后面排查章节再提。3.4 用浏览器和curl验证跨域是否生效服务端改完后先在浏览器按F12打开Network面板输入前端请求地址。看两个要点第一个是请求预期时Network列表里会不会多出一个类型为OPTIONS的请求第二个是OPTIONS是否返回200响应头是否包含Access-Control-Allow-*。如果OPTIONS是200但后续请求还是被拦截大概率是Allow-Headers里缺少实际请求头。也可以用curl直接模拟预检这样排查更精准curl -X OPTIONS https://api.example.com/handlers/user.ashx \ -H Origin: https://app.example.com \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: Content-Type \ -i它会把完整的响应头打出来你直接看里面有没有允许Origin回显、方法、头字段。实测是排查“服务端配了但没生效”的最快路径。4. 常见问题与排查技巧4.1 OPTIONS预检请求返回405或状态码异常这是最频繁的翻车现场。IIS默认不打招呼就直接把OPTIONS请求返回405导致预检过不了。我之前说过基类里要专门处理OPTIONS并设置Status 200。如果你是全局用Application_BeginRequest处理也可以这样写protected void Application_BeginRequest(object sender, EventArgs e) { var context HttpContext.Current; if (context.Request.HttpMethod OPTIONS) { context.Response.StatusCode 200; context.Response.AddHeader(Access-Control-Allow-Origin, context.Request.Headers[Origin]); context.Response.AddHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); context.Response.AddHeader(Access-Control-Allow-Headers, Content-Type, Accept); context.Response.End(); } }如果在你项目里OPTIONS还是过不去检查一下是否装了某些安全组件或URL重写模块它们可能会抢先拦掉请求。也可以用日志直接看请求是否到达Global.asax。4.2 响应头带了浏览器依旧报错这种情况多数发生在Access-Control-Allow-Origin: *配合请求头Content-Type: application/json的场景。你看到响应头确实有Allow-Origin但浏览器在预检阶段就拦住后面的正式请求因为Access-Control-Allow-Headers没有声明Content-Type。浏览器很较真预检允许什么头正式请求才能带什么头。还有一种场景是前端用了自定义Header比如X-Token、Authorization。这些头都得在Access-Control-Allow-Headers里列出来。我的做法是直接在基类里把所有可能用到的头都加上省得前后端扯皮。注意Accept、X-Requested-With也经常会用到Authorization如果以后要加JWT也建议提前留出。4.3 加了Allow-Credentials后为什么反而取不到Cookie出现这个现象的典型原因是服务端把Access-Control-Allow-Origin写成了固定的*同时设置了Allow-Credentials: true。浏览器一看这种组合直接把凭据机制停掉。你必须在代码里动态获取Origin请求头并回写不能是通配符。另外前端也要配合设置$.ajax({ url: http://api.example.com/handlers/user.ashx, type: POST, xhrFields: { withCredentials: true }, success: function (res) { console.log(res); } });这里有个容易被忽略的点跨域时Cookie的域名归属必须是你接口的域名不是前端页面域名。如果登录态Cookie是前端域下的跨域调用时浏览器不会自动带上服务端Session自然为空。要让接口正确拿Session登录接口本身也得跨域、或者把Cookie域设置为根域。这块配置因浏览器版本差异比较大建议联调时打开Network看请求带不带Cookie再判断。4.4 部分热词里提到的ERR_CONNECTION_RESET或ERR_PROXY_CONNECTION_FAILED有些开发者会把所有网络异常都归到跨域头上。实际上像net::ERR_CONNECTION_RESET、net::ERR_PROXY_CONNECTION_FAILED这类错误绝大多数是网络层或代理层的问题不是CORS策略。浏览器跨域拦截抛出的是Access to XMLHttpRequest at ... has been blocked by CORS policy不是以net::ERR_开头。排查时如果报的是ERR_CONNECTION_RESET优先看服务器端口通不通、服务是否挂了、请求是否被WAF或防火墙断了、是不是触发了HTTP/2连接复用异常等。跨域问题看了老半天却把这类网络故障混在一起容易走火入魔。4.5 常见问题速查表现象大概率原因解决方向OPTIONS返回405IIS未处理预检请求在基类或Global.asax放行OPTIONS返回200正式请求被拦截控制台提示Header not allowedAllow-Headers缺内容补充Content-Type、X-Requested-With等实际头Cookie/Session丢失Allow-Origin是*或前端没开withCredentials回显Origin关闭通配符前端设withCredentials换HTTPS后接口直接失败页面和接口协议不匹配检查证书和HSTS配置跟CORS无关本地能通线上不行环境配置/代理差异用curl带同样的Origin单独测线上这张表是我实际排障时最常用的自查路径。每一条都对应一类重复出现的报错看一眼基本能定位。5. 近期联调中的一个实例总结最后再多啰嗦一句把整个处理流程串一个真实的小例子。我接手的外部系统里有三个ashx分别负责用户信息、订单状态和导出。前一个开发小哥直接在三个文件里复制了Access-Control-Allow-Origin: *结果前端发JSON体POST时一直报预检错。原因就出在三个文件都没有处理OPTIONS并且其中一个接口需要Session取操作人*根本没法配合Cookie。后来我把三个文件做了一次瘦身写了一个BaseHandler抽象类每个ashx继承它按照我上面的代码配置好白名单和Allow-Headers。联调半小时前端一次通过连OPTIONS都自动放行了。后面新增接口只要继承基类不用再关心跨域。我个人在实际操作中的体会是面对弱类型的WebForm老代码改动越集中越安全。与其在每个ashx里加响应头不如一开始就建基类统一收口把跨域、编码、异常处理都收在一起。这样做完不只是解决问题还能顺手把后续维护成本降下来。如果你现在手头也是老WebForm项目、前端页面分散在多个域名我的建议是直接抄基类方案再根据白名单放宽来源。如果发现某个外部接口的调用方域名确实很多可以把白名单改成从AppSettings或数据库读取但切记不要为了图省事无脑开放通配符。毕竟跨域是浏览器的安全门我们把门打开并不代表允许所有人在门口随意进出。
返回列表