在前端开发过程中尝试向某个接口发送跨域请求时发生了跨域错误,后端跨域设置正确且预检请求正常,但实际跨域请求失败且控制台显示缺少 Access-Control-Allow-Origin 响应头。实际问题源自前端接口请求函数中错误的 URL 格式,测试同学在 Postman 中测试时错误地添加了多余的“/”,导致在实际请求时出现了 307 重定向响应。尽管预检请求通过,但重定向响应缺乏正确的跨域响应头,导致浏览器拦截了请求。此外,Postman 中的设置使其自动处理重定向,隐藏了 307 响应,而后端服务器返回了 307 重定向。这导致测试同学在 Postman 中看到了 200 响应,没有注意到问题。 最终修复了前端代码中的 URL 格式,确保正确的跨域响应头,解决了奇怪的跨域问题。

问题的产生

今天在进行前端开发时遇到了一个奇怪的问题,向某个接口发送一个delete请求时,发生了跨域错误,如图所示:

后端跨域都已经进行了设置,乍一看好像就是后端跨域没做好一样,但我们从“开发者调试工具-网络”来看预检请求是正常的,而且里面Access-Control-Allow-Origin:*,允许的方法中也包含delete,但delete请求就是失败的,而且控制台报错说没有Access-Control-Allow-Origin这个响应头,但这响应头明明就在那摆着呢,怎么会没有呢?但这个307状态码也确实引起了我的注意,我隐约记得浏览器屏蔽不合规的跨域请求显示的信息和这个形式类似,但哪有什么307状态码?,很显然这个307状态码是后端响应的,但查询后端gin的日志根本就没有307的相关记录,相反还出现了一条对这个路径进行get请求的日志对应的响应的是404,浏览器这面也没有相关的get请求记录,这就非常离谱了。

我们使用postman发送请求,发现接口可以正常响应,这也说明后端没有问题,代码里面也没有重定向相关的逻辑。

调整后端逻辑无效

虽然不是后端的问题,但我们也要死马当活马医:

根据报错:“has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.”我们也尝试把后端跨域中间件的Access-Control-Allow-Origin从“*”改为http://指定的主机名:端口,因为我们猜测允许全部主机而不是特定主机可能会被浏览器进行限制,然而我们更改完指定Origin后仍然不奏效。

考虑到前端的其他api函数进行跨域请求都是正常的,我们首先怀疑有可能是浏览器限制了DELETE请求的跨域发送,但通过查阅有关资料,并没有发现浏览器对delete请求跨域有特殊的限制。

我们又尝试换用官方的跨域中间件来解决这个问题,但是依然不奏效。

package main

import (
  "time"

  "github.com/gin-contrib/cors"
  "github.com/gin-gonic/gin"
)

func main() {
  router := gin.Default()
  // CORS for https://foo.com and https://github.com origins, allowing:
  // - PUT and PATCH methods
  // - Origin header
  // - Credentials share
  // - Preflight requests cached for 12 hours
  router.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"https://foo.com"},
    AllowMethods:     []string{"PUT", "PATCH"},
    AllowHeaders:     []string{"Origin"},
    ExposeHeaders:    []string{"Content-Length"},
    AllowCredentials: true,
    AllowOriginFunc: func(origin string) bool {
      return origin == "https://github.com"
    },
    MaxAge: 12 * time.Hour,
  }))
  router.Run()
}

我们准备进一步排查,首先将后端代码撤销前几步的修改,在前端调用了另一个同样的发送delete请求的api,发现竟可以成功调用,这可以说明:很明显不是浏览器对delete请求跨域有特殊的限制或者后端的跨域设置有问题。真正的问题应该出现在前端的这个接口请求函数上。

问题原因

我仔细的查看了307里面响应的结果,才有了重大发现,如图所示,这个响应结果明显是服务器要求把article/deleteArticle/?aId=1099重定向至article/deleteArticle?aId=1099,原因是axios请求的url参数前面多加了一个“/”,gin返回了307要求把“/”重定向掉,我推测gin中肯定有相关逻辑,把参数前面的“/”重定向掉,而不是直接返回404,现在看来:这个功能不但没有什么方便可言,反到增加了不少麻烦。

Postman中的Swagger是测试同学写的,他测api的时候不小心在url参数开始符号“?”前面加了个”/”,然后但前端同学直接照搬了postman中的内容,才导致前端产生了这个离谱的bug。

重定向响应存在的问题

但为什么重定向会导致跨域失败呢?因为这个重定向307响应里面没有跨域响应头,这个响应是gin底层代码里面设计的,不走业务代码里面的跨域中间件,也不打印日志,这明显增加了问题的复杂度,这就好比你的服务器只有预检请求返回跨域设置,然后后来的请求响应头却没有跨域设置一样。

func CorsMiddleware(c *gin.Context) {

	if c.Request.Method == "OPTIONS" {
		c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
		c.Writer.Header().Set("Access-Control-Max-Age", "86400")
		c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE")
		c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
		c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length")
		c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
		
		c.AbortWithStatus(204)
	} else {
		c.Next()
	}
	
}

浏览器在发送”复杂请求”之前需要发送预检请求,这样设计是为了防止后续的“非幂等”请求对服务器产生副作用,但即使预检请求通过,只是放行后续请求而已,如果后续请求的跨域头不正确依然会被浏览器拦截(虽然服务器已经正常处理,浏览器也会拒绝将结果返回给js,包括今天的307重定向也不行)。

简单请求和复杂请求

划分简单请求和复杂请求的依据是“是否产生副作用”。这里的副作用指对数据库做出修改,对于设计规范的REST接口:使用GET请求获取新闻列表,数据库中的记录不会做出改变,而使用PUT请求去修改一条记录,数据库中的记录就发生了改变。

简单请求

请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type
  • DPR
  • Downlink
  • Save-Data
  • Viewport-Width
  • Width

Content-Type的值只有以下三种(post)

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

复杂请求

不是简单请求都按照复杂请求处理

对于简单请求,浏览器只会在请求头加上一个origin字段标识请求来源;对于非简单请求,浏览器会先发出一个预检请求,获得肯定回答后才会发送真正的请求。

可以假设网站被CSRF攻击了——黑客网站向银行的服务器发起跨域请求,并且这个银行的安全意识很弱,只要有登录凭证cookie就可以成功响应:

  1. 黑客网站发起一个GET请求,目的是查看受害用户本月的账单。银行的服务器会返回正确的数据,不过影响并不大,而且由于浏览器的拦截,最后黑客也没有拿到这份数据;
  2. 黑客网站发起一个PUT请求,目的是把受害用户的账户余额清零。浏览器会首先做一次预检,发现收到的响应并没有带上CORS响应头,于是真正的PUT请求不会发出;

下图我们模拟了:服务器不允许进行跨域DELETE请求的情况,可以看出后续请求并没有什么307,而是直接就禁止了。

func CorsMiddleware(c *gin.Context) {

	c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
	c.Writer.Header().Set("Access-Control-Max-Age", "86400")
	c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, UPDATE")
	c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
	c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length")
	c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
	
	if c.Request.Method == "OPTIONS" {
		c.AbortWithStatus(204)
	} else {
		c.Next()
	}
}

为什么测试没发现

为什么测试同学没有发现自己写接口的url写错了呢?为什么postman是200呢?后端为什么没有返回307呢?因为postman里面有一个设置项叫Automatically follow redirects,服务器返回了3XX就会自动重定向,而且postman根本不涉及浏览器的跨域保护限制,自然就感觉不出来了。当我们把这个设置关掉,也就显示307状态码了。

什么是跨域

跨域这个问题只会出现在浏览器中,同源策略是一种安全机制,它要求网页中的脚本只能与加载这个脚本的页面具有相同的协议、域名和端口。换句话说,只有在同一个域下的网页才能相互访问对方的资源,不能直接访问不同源的资源。

例如:A网页的脚本(通常是JavaScript)试图向一个不同源(域名、协议或端口)的B网站中获取/修改资源,这种行为就被称为跨域请求。浏览器出于安全考虑,限制了这种请求。这种限制不是B服务器直接拒绝的(因为某些情况下B服务器不通过特定手段就无法知道请求发出者是否合法),而是靠合规的浏览器根据当前访问的网站自行遵守同源策略,用于保护B网站不会在不知情的情况下被A网站中的脚本请求,或者即使请求成功了也不会把结果送给A网站中的脚本。但B服务器也可以给浏览器一些“建议”(跨域响应头)告诉浏览器在运行A站的脚本时可以对B服务器做出哪些请求,浏览器就会放行相应的请求,以满足必要的跨站需求。

总结

我们遇到一些百度不出来的奇怪的问题以后,不要慌,要抽丝剥茧,用理性的思考逐步排除故障,积累更多的技术和经验,用全栈工程师的思维去思考,什么问题都会迎刃而解。