最近升级某个依赖库,遇到cookie解析失败的问题,网上查了查资料,在这里学习记录一下。
背景
近日有用户反馈tomcat升级后应用出现了一些问题,出现问题的这段时间内,tomcat从8.0.47
升级到了8.5.43
。 问题主要分为两类:
- cookie写入过程中,domain如果以
.
开头则无法写入,比如.xx.com
写入会报错,而写入xx.com
则没问题。 - cookie读取后应用无法解析,写入cookie的值采用的是
Base64
算法。
定位
经过一番搜索,发现tomcat在这两个版本中,cookie的写入和解析策略确实发生了一些变化,可见Tomcat的文档,里面有这么一段提示:
The standard implementation of CookieProcessor is org.apache.tomcat.util.http.LegacyCookieProcessor. Note that it is anticipated that this will change to org.apache.tomcat.util.http.Rfc6265CookieProcessor in a future Tomcat 8 release.
由于8.0
过后就直接到了8.5
,
- 从
8.5
开始就默认使用了org.apache.tomcat.util.http.Rfc6265CookieProcessor
- 而之前的版本中一直使用的是
org.apache.tomcat.util.http.LegacyCookieProcessor
,
下面就来看看这两种策略到底有哪些不同.
LegacyCookieProcessor
org.apache.tomcat.util.http.LegacyCookieProcessor
主要是实现了标准RFC6265
, RFC2109
和 RFC2616
.
写入cookie
写入cookie的逻辑都在generateHeader
方法中. 这个方法逻辑大概是:
- 直接拼接
cookie.getName()
然后拼接=
. - 校验
cookie.getValue()
以确定是否需要为value
加上引号.
private void maybeQuote(StringBuffer buf, String value, int version) {
if (value == null || value.length() == 0) {
buf.append("\"\"");
} else if (alreadyQuoted(value)) {
buf.append('"');
escapeDoubleQuotes(buf, value,1,value.length()-1);
buf.append('"');
} else if (needsQuotes(value, version)) {
buf.append('"');
escapeDoubleQuotes(buf, value,0,value.length());
buf.append('"');
} else {
buf.append(value);
}
}
private boolean needsQuotes(String value, int version) {
...
for (; i < len; i++) {
char c = value.charAt(i);
if ((c < 0x20 && c != '\t') || c >= 0x7f) {
throw new IllegalArgumentException(
"Control character in cookie value or attribute.");
}
if (version == 0 && !allowedWithoutQuotes.get(c) ||
version == 1 && isHttpSeparator(c)) {
return true;
}
}
return false;
}
只要cookie value中出现如下任一一个字符就会被加上引号再传输.
// separators as defined by RFC2616
String separators = "()<>@,;:\\\"/[]?={} \t";
private static final char[] HTTP_SEPARATORS = new char[] {
'\t', ' ', '\"', '(', ')', ',', ':', ';',
'<', '=', '>', '?', '@',
'[', '\\', ']', '{', '}' };
- 拼接
domain
字段,如果满足上面加引号的条件,也会被加上引号. - 拼接
Max-Age
和Expires
. - 拼接
Path
,如果满足上面加引号的条件,也会被加上引号. - 直接拼接
Secure
和HttpOnly
.
值得一提的是,LegacyCookieProcessor
这种策略中,domain
可以写入.xx.com
,而在Rfc6265CookieProcessor
中会校验不能以.
开头.
解析cookie
在这种LegacyCookieProcessor
策略中,对有引号和value和没有引号的value执行了两种不同的解析方法.代码逻辑在processCookieHeader
方法中,
简单来说 :
1.对于有引号的value,解析的时候value就是两个引号之间的值.代码可以参考,主要就是getQuotedValueEndPosition
在处理.
2.对于没有引号的value.则执行getTokenEndPosition
方法,这个方法如果碰到HTTP_SEPARATORS
中任何一个分隔符,则视为解析完成.
Rfc6265CookieProcessor
写入cookie
写入cookie的逻辑和上面类似,只是校验发生了变化
- 直接拼接
cookie.getName()
然后拼接=
. - 校验
cookie.getValue()
,只要没有特殊字段就通过校验,不会额外为特殊字符加引号.
private void validateCookieValue(String value) {
int start = 0;
int end = value.length();
if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') {
start = 1;
end--;
}
char[] chars = value.toCharArray();
for (int i = start; i < end; i++) {
char c = chars[i];
if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) {
throw new IllegalArgumentException(sm.getString(
"rfc6265CookieProcessor.invalidCharInValue", Integer.toString(c)));
}
}
}
复制代码
对于码表如下:
- 拼接
Max-Age
和Expires
. - 拼接
Domain
.增加了对domain 的校验. (domain必须以数字或者字母开头,必须以数字或者字母结尾) - 拼接
Path
,path 字符不能为;
,不能小于0x20
,不能大于0x7e
; - 直接拼接
Secure
和HttpOnly
.
通过与LegacyCookieProcessor
对比可知, Rfc6265CookieProcessor
不会对某些特殊字段的value加引号,其实都是因为这两种策略实现的规范不同而已.
解析cookie
解析cookie主要在parseCookieHeader
中,和上面类似,也是对引号有特殊处理,
- 如果有引号,只获取引号之间的部分,
- 没有引号的时候会判断value是否有
括号
,空格
,tab
,如果有,则会会视为结束符.
解释
再回到文章开始的两个问题,如果都使用tomcat的默认配置:
- 由于
tomcat8.5
以后都使用了Rfc6265CookieProcessor
,所以domain
只能用xx.com
这种格式. Base64
由于会用=
补全,而=
在LegacyCookieProcessor
会被视为特殊符号,导致Rfc6265CookieProcessor
写入的cookie没有引号,LegacyCookieProcessor
在解析value的时候遇到=
就结束了,所以老版本的tomcat无法正常工作,只能获取到=
前面一截.
解决方法
从以上代码来看,其实LegacyCookieProcessor
可以读取Rfc6265CookieProcessor
写入的cookie.而且Rfc6265CookieProcessor
可以正常读取LegacyCookieProcessor
写入额cookie .那么在新老版本交替中,我们把tomcat的的CookieProcessor
都设置为LegacyCookieProcessor
,即可解决所有问题.
如何设置
传统Tomcat
修改conf
文件夹下面的context.xml
,增加CookieProcessor
配置在Context
节点下面:
<Context>
<CookieProcessor className="org.apache.tomcat.util.http.LegacyCookieProcessor" />
</Context>
Spring Boot
对于只读cookie不写入的应用来说,不必修改,如果要修改,可以增加如下配置即可.
@Bean
public EmbeddedServletContainerCustomizer cookieProcessorCustomizer() {
return new EmbeddedServletContainerCustomizer() {
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
if (container instanceof TomcatEmbeddedServletContainerFactory) {
((TomcatEmbeddedServletContainerFactory) container)
.addContextCustomizers(new TomcatContextCustomizer() {
@Override
public void customize(Context context) {
context.setCookieProcessor(new LegacyCookieProcessor());
}
});
}
}
};
}
其他错误
An invalid character [32] was present in the Cookie value
Spring Boot使用的内嵌Tomcat不能开箱即用的支持Version 0
的Cookie格式,你可能会看到以下错误:
java.lang.IllegalArgumentException: An invalid character [32] was present in the Cookie value
java.lang.IllegalArgumentException: An invalid character [xx] was present in the Cookie value at org.apache.tomcat.util.http.Rfc6265CookieProcessor.validateCookieValue(Rfc6265CookieProcessor.java:162) at org.apache.tomcat.util.http.Rfc6265CookieProcessor.generateHeader(Rfc6265CookieProcessor.java:111) ...
规范变化
Tomcat 8.x( or later)版本进了很多改进,其中的 Cookie 处理也升级到 RFC6265 规范,这可能导致在 Tomcat 8 以前版本中运行无问题的Web项目在 Tomcat 8 中报下面错误:
java.lang.IllegalArgumentException: An invalid character [34] was present in the Cookie value
上面的 [34] 中的 34 是指 ASCII 码(十进制)对应的字符 “(双引号)。那么在不明确知道 RFC6265 规范中 Cookie 值可用的字符时,可能在 Cookie 值使用其他字符也会出现上面的问题。
解决
可以的话,你需要考虑将代码升级到只存储遵从最新版Cookie定义的值。如果不能改变写入的cookie,你可以配置Tomcat使用LegacyCookieProcessor
。通过向EmbeddedServletContainerCustomizer
bean添加一个TomcatContextCustomizer
可以开启LegacyCookieProcessor
:
@Bean
public EmbeddedServletContainerCustomizer cookieProcessorCustomizer() {
return new EmbeddedServletContainerCustomizer() {
@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
if (container instanceof TomcatEmbeddedServletContainerFactory) {
((TomcatEmbeddedServletContainerFactory) container)
.addContextCustomizers(new TomcatContextCustomizer() {
@Override
public void customize(Context context) {
context.setCookieProcessor(new LegacyCookieProcessor());
}
});
}
}
};
}
An invalid domain [.xxx.com] was specified for this cookie exception resolution
tomcat8.5版本默认使用的是rfc6265实现的,而tomcat8.0版本是LegacyCookieProcessor
而在rfc6265中看到了域属性这段话,域属性不要以dot开头
所以,类似的cookie.setDomain(".test.com");在rfc6265标准中应该改为cookie.setDomain(“test.com”),即开头不要加点号
规则:
- 必须是1-9、a-z、A-Z、. 、- (注意是-不是_)这几个字符组成
- 必须是数字或字母开头 (所以以前的cookie的设置为.XX.com 的机制要改为 XX.com 即可)
- 必须是数字或字母结尾
将cookie处理的手工设置为LegacyCookieProcessor即可
如果是Spring boot中也是类似的道理
参考文档:https://docs.spring.io/spring-boot/docs/2.0.3.RELEASE/reference/htmlsingle/#howto-use-tomcat-legacycookieprocessor
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> cookieProcessorCustomizer() {
return (factory) -> factory.addContextCustomizers(
(context) -> context.setCookieProcessor(new LegacyCookieProcessor()));
}
————————————————
Tomcat will allow HTTP separators in cookie names and values.
需要设置:cp.setAllowHttpSepsInV0(true);
Apache Tomcat 9 Configuration Reference (9.0.50) - The Cookie Processor Component
public void setAllowHttpSepsInV0(boolean allowHttpSepsInV0) {
this.allowHttpSepsInV0 = allowHttpSepsInV0;
// HTTP separators less comma, semicolon and space since the Netscape
// spec defines those as separators too.
// '/' is also treated as a special case
char[] seps = "()<>@:\\\"[]?={}\t".toCharArray();
for (char sep : seps) {
if (allowHttpSepsInV0) {
allowedWithoutQuotes.set(sep);
} else {
allowedWithoutQuotes.clear(sep);
}
}
if (getForwardSlashIsSeparator() && !allowHttpSepsInV0) {
allowedWithoutQuotes.clear('/');
} else {
allowedWithoutQuotes.set('/');
}
}
TestLegacyCookieProcessor.java
package org.apache.tomcat.util.http;
import java.nio.charset.StandardCharsets;
import org.junit.Assert;
import org.junit.Test;
import org.apache.tomcat.util.buf.MessageBytes;
public class TestLegacyCookieProcessor {
/*
* https://bz.apache.org/bugzilla/show_bug.cgi?id=59925
*/
@Test
public void testV0WithPath() {
LegacyCookieProcessor cp = new LegacyCookieProcessor();
cp.setAllowHttpSepsInV0(true);
cp.setForwardSlashIsSeparator(true);
MimeHeaders mimeHeaders = new MimeHeaders();
ServerCookies serverCookies = new ServerCookies(4);
MessageBytes cookieHeaderValue = mimeHeaders.addValue("Cookie");
byte[] bytes = "$Version=0;cname=cvalue;$Path=/example".getBytes(StandardCharsets.UTF_8);
cookieHeaderValue.setBytes(bytes, 0, bytes.length);
cp.parseCookieHeader(mimeHeaders, serverCookies);
Assert.assertEquals(1, serverCookies.getCookieCount());
for (int i = 0; i < 1; i++) {
ServerCookie actual = serverCookies.getCookie(i);
Assert.assertEquals(0, actual.getVersion());
Assert.assertEquals("cname", actual.getName().toString());
actual.getValue().getByteChunk().setCharset(StandardCharsets.UTF_8);
Assert.assertEquals("cvalue",
org.apache.tomcat.util.http.parser.Cookie.unescapeCookieValueRfc2109(
actual.getValue().toString()));
Assert.assertEquals("/example", actual.getPath().toString());
}
}
}
引用
java - How to change Cookie Processor to LegacyCookieProcessor in tomcat 8 - Stack Overflow
Tomcat中LegacyCookieProcessor与Rfc6265CookieProcessor - 掘金
springboot 1.5.x 使用tomcat8设置cookie的domain以dot开头报错 - 园芳宝贝 - 博客园