什么是同源策略

同源策略(same origin policy)指的是,两个网页的协议、域名和端口都相同。 但 Windows RT IE 是例外的,对它而言,端口号并不是同源策略的组成部分之一。

同源策略的变化

同源策略最初的要求是,同源的网页才能打开同源网页下的 cookie。cookie 实际上是一种对浏览器用户总是可见,但对 javascript 代码不总可见的内容。

但现代的同源策略起了轻微的变化:

  1. localStorage 和 IndexedDB 也受同源策略限制。
  2. “Cookies使用不同的源定义方式。一个页面可以为本域和任何父域设置cookie,只要是父域不是公共后缀(public suffix)即可。设置cookie时,你可以使用 Domain,Path,Secure,和 Http-Only 标记来限定其访问性。”
  3. XMLHttpRequest<img>标签则会受到同源策略的约束:
    1. 通常允许进行跨域写操作(Cross-origin writes)。例如链接,重定向以及表单提交(基于 get 实现写,如下面讲到的 img)。特定少数的 HTTP 请求需要添加 preflight。
    2. 允许跨域资源嵌入(Cross-origin embedding)。也就是说图片插入本质上还是不受同源策略的限制。这恐怕也是现阶段的很多 CSRF 攻击的根源。因为 img 的src可以写一个平凡的url,这个 get 请求可能会让隐藏请求悄无声息地发生。
  4. 跨源读操作(Cross-origin reads)一般是不被允许的,但可以通过内嵌资源来巧妙地进行读取访问。也就是说,原始的跨域 post 请求本身是很容易被 banned 掉的。现实中浏览器的例子是,能发 request 不能收 response。即 xhr 的写可以被允许,读很危险,读比写危险,读会泄露机密。
  5. DOM 无法跨域访问。

如何阻止跨域访问

csrf攻击

  • 阻止跨域访问,只要检测请求中的 CSRF token 即可。换言之,CSRF 攻击的根源还是跨域 post 成功(这又因为 xhr被部分允许跨域写。WEB的身份验证机制虽然可以保证一个请求是来自于某个用户的浏览器,但却无法保证该请求是用户批准发送的)。
  • 阻止资源的跨站读取和读取,需要保证该资源是不可嵌入的。阻止嵌入行为是必须的,因为嵌入资源通常向其暴露信息。(其实早期的 CSRF 攻击有把一个 http 地址隐藏在一个 img 元素里的用法)。

如何破解同源策略

修改源

这个方法但只能升源,不能跨源,但能改变 cookie 从属的域。

页面可以修改自己的源,但只能用它的脚本将document.domain的值设置成其当前域或当前域的超级域。如果将其设置为当前域的超级域,则较短的域将用于后续原始检查。

MDN 里举了一个例子,假设文档中的一个脚本在 http://store.company.com/dir/other.html 执行以下语句::

1
document.domain = "company.com";

页面将会成功地通过对 http://company.com/dir/page.html 的同源检测。而同理,company.com 不能设置 document.domain 为 othercompany.com。

但改域还是要注意端口号问题:

浏览器单独保存端口号。任何的赋值操作,包括document.domain = document.domain都会以null值覆盖掉原来的端口号。因此company.com:8080页面的脚本不能仅通过设置document.domain = “company.com”就能与company.com通信。赋值时必须带上端口号,以确保端口号不会为null。

还有一个需要对父页面重新赋值的注意事项:

使用document.domain允许子域安全访问其父域时,您需要设置document.domain在父域和子域中具有相同的值。这是必要的,即使这样做只是将父域设置回其原始值。否则可能会导致权限错误。

这个例子似乎是要说,子域 a 通过修改 document.domain 让子域 b 访问自己的特定 cookie。

iframe

如果两个网页不同源,就无法拿到对方的DOM。典型的例子是iframe窗口和window.open方法打开的窗口,它们与父窗口无法通信。

HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。

这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。

举例来说,父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法就可以了。

Javascript 里有一个经典的“不经授权的 js 片段读取内存里的信息”的问题,这类问题怎么解决?

AJAX 的方法

简单 header 和非简单 header

默认情况下,只有七种 simple response headers (简单响应首部)可以暴露给外部:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

non-simple-headers 可以用如下方式返回:

Access-Control-Expose-Headers: Content-Length, X-Kuma-Revision

JSONP

<script>标签的存在,生动地说明了同源策略不限制普通的 http get 请求获取嵌入式资源。本质上就是让代码的上文写好,生成一个script标签请求,让服务器把下文写好。大致的例子是:

先在客户端生成一个标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}

window.onload = function () {
/**
* 实际上大概生成了这样一个 tag:
* <script src="http://example.com/ip?callback=foo"></script>
*/
addScriptTag('http://example.com/ip?callback=foo');
}

function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};

然后再在服务器端生成一段 JavaScript 代码:

1
2
3
foo({
"ip": "8.8.8.8"
});

WebSocket

WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策(其实只有 AJAX 受到同源策略的限制),只要服务器支持,就可以通过它进行跨源通信。

CORS(Cross-Origin Resource Sharing)

先把请求分成简单请求(simple request)和非简单请求(not-so-simple request)。

简单请求

简单请求要求同时满足两大条件:
其他请求都是非简单请求。

对于简单请求,就是在 request 里面表明当前的request 来自哪个 origin。换言之,A 要跨域到 B,A 至少要表明自己。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

1
2
3
4
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

其中各个字段的含义:

  1. Access-Control-Allow-Origin:该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个,表示接受任意域名的请求(习惯大方的程序员当然会选择后者了)。

  2. Access-Control-Allow-Credentials:该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。

  3. Access-Control-Expose-Headers:该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。上面的例子指定,getResponseHeader(‘FooBar’)可以返回FooBar字段的值。

非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。

非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

简而言之,在 JQuery 时代,由浏览器而不是由 JQuery 自动发出的 OPTIONS 请求,就是 preflight 请求。

一个例子如下。

1
2
3
4
5
var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代码中,HTTP请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header。

浏览器发现,这是一个非简单请求,就自动发出一个”预检”请求,要求服务器确认可以这样请求。下面是这个”预检”请求的HTTP头信息。

1
2
3
4
5
6
7
8
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

服务器收到了这个请求,返回一个这样的响应:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示http://api.bob.com可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。

如果浏览器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。

到此我们可以看到,跨域相关的许可信息,都是放在 header 里而不是放在 body 里的。

一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。这两个字段,是浏览器和服务器自动添加上去,保证通话过程始终在对跨域的警惕和授权中度过

附一个小问题:CSRF 攻防问题

  1. img src 攻击可以攻击所有 get 请求。所以现代的电子邮箱默认都不显示邮件图片,否则不知道哪个图片会攻击某个还能使用 get url 访问的网站。
  2. 隐藏表单不受同源策略影响,post 也需要做专门防御。任意的空白网页里都可能存在一个表单,在 onload 函数里就把表单提交了。

最完善的做法,应该是做一些有时效性的 token 放在网页里。像 Rails 的方案,就是一个隐藏表单里的 token,还要配合 referer 使用(这个字段能不能被 javascript 修改是个复杂问题)。

tomcat 关于会话的实现

对 tomcat 而言,会话存在于 cookie、url重写(“;jsessionid=xxxxxx”)、隐藏表单域、ssl 属性。

使用的一般字段是 JSESSIONID。

tomcat四层结构

engine -> host -> context -> manager。

tomcat session 组件图如下所示,其中 Context 对应一个 webapp 应用,每个 webapp 有多个 HttpSessionListener, 并且每个应用的 session 是独立管理的,而 session 的创建、销毁由 Manager 组件完成,它内部维护了 N 个 Session 实例对象。在前面的文章中,我们分析了 Context 组件,它的默认实现是 StandardContext,它与 Manager 是一对一的关系,Manager 创建、销毁会话时,需要借助 StandardContext 获取 HttpSessionListener 列表并进行事件通知,而 StandardContext 的后台线程会对 Manager 进行过期 Session 的清理工作。

tomcat-session的结构

context 与 manager 是一对一的关系,而且 manger 负责通过以下代码创建 session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
// 获取 request 对应的 session
public HttpSession getSession() {
// 这里就是 通过 managerBase.sessions 获取 Session
Session session = doGetSession(true);
if (session == null) {
return null;
}
return session.getSession();
}

// create 代表是否创建 StandardSession
protected Session doGetSession(boolean create) {

// There cannot be a session if no context has been assigned yet
// 1. 检验 StandardContext
if (context == null) {
return (null);
}

// Return the current session if it exists and is valid
// 2. 校验 Session 的有效性
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
return (session);
}

// Return the requested session if it exists and is valid
Manager manager = null;
if (context != null) {
//拿到StandardContext 中对应的StandardManager,Context与 Manager 是一对一的关系
manager = context.getManager();
}
if (manager == null)
{
return (null); // Sessions are not supported
}
if (requestedSessionId != null) {
try {
// 3. 通过 managerBase.sessions 获取 Session
// 4. 通过客户端的 sessionId 从 managerBase.sessions 来获取 Session 对象
session = manager.findSession(requestedSessionId);
} catch (IOException e) {
session = null;
}
// 5. 判断 session 是否有效
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
// 6. session access +1
session.access();
return (session);
}
}

// Create a new session if requested and the response is not committed
// 7. 根据标识是否创建 StandardSession ( false 直接返回)
if (!create) {
return (null);
}
// 当前的 Context 是否支持通过 cookie 的方式来追踪 Session
if ((context != null) && (response != null) && context.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.COOKIE) && response.getResponse().isCommitted()) {
throw new IllegalStateException
(sm.getString("coyoteRequest.sessionCreateCommitted"));
}

// Attempt to reuse session id if one was submitted in a cookie
// Do not reuse the session id if it is from a URL, to prevent possible
// phishing attacks
// Use the SSL session ID if one is present.
// 8. 到这里其实是没有找到 session, 直接创建 Session 出来
if (("/".equals(context.getSessionCookiePath()) && isRequestedSessionIdFromCookie()) || requestedSessionSSL ) {
session = manager.createSession(getRequestedSessionId()); // 9. 从客户端读取 sessionID, 并且根据这个 sessionId 创建 Session
} else {
session = manager.createSession(null);
}

// Creating a new session cookie based on that session
if ((session != null) && (getContext() != null)&& getContext().getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.COOKIE)) {
// 10. 根据 sessionId 来创建一个 Cookie
Cookie cookie = ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure());
// 11. 最后在响应体中写入 cookie
response.addSessionCookieInternal(cookie);
}

if (session == null) {
return null;
}
// 12. session access 计数器 + 1
session.access();
return session;
}

// 获取 request 对应的 session
public HttpSession getSession() {
// 这里就是 通过 managerBase.sessions 获取 Session
Session session = doGetSession(true);
if (session == null) {
return null;
}
return session.getSession();
}

// create 代表是否创建 StandardSession
protected Session doGetSession(boolean create) {

// There cannot be a session if no context has been assigned yet
// 1. 检验 StandardContext
if (context == null) {
return (null);
}

// Return the current session if it exists and is valid
// 2. 校验 Session 的有效性
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
return (session);
}

// Return the requested session if it exists and is valid
Manager manager = null;
if (context != null) {
//拿到StandardContext 中对应的StandardManager,Context与 Manager 是一对一的关系
manager = context.getManager();
}
if (manager == null)
{
return (null); // Sessions are not supported
}
if (requestedSessionId != null) {
try {
// 3. 通过 managerBase.sessions 获取 Session
// 4. 通过客户端的 sessionId 从 managerBase.sessions 来获取 Session 对象
session = manager.findSession(requestedSessionId);
} catch (IOException e) {
session = null;
}
// 5. 判断 session 是否有效
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
// 6. session access +1
session.access();
return (session);
}
}

// Create a new session if requested and the response is not committed
// 7. 根据标识是否创建 StandardSession ( false 直接返回)
if (!create) {
return (null);
}
// 当前的 Context 是否支持通过 cookie 的方式来追踪 Session
if ((context != null) && (response != null) && context.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.COOKIE) && response.getResponse().isCommitted()) {
throw new IllegalStateException
(sm.getString("coyoteRequest.sessionCreateCommitted"));
}

// Attempt to reuse session id if one was submitted in a cookie
// Do not reuse the session id if it is from a URL, to prevent possible
// phishing attacks
// Use the SSL session ID if one is present.
// 8. 到这里其实是没有找到 session, 直接创建 Session 出来
if (("/".equals(context.getSessionCookiePath()) && isRequestedSessionIdFromCookie()) || requestedSessionSSL ) {
session = manager.createSession(getRequestedSessionId()); // 9. 从客户端读取 sessionID, 并且根据这个 sessionId 创建 Session
} else {
session = manager.createSession(null);
}

// Creating a new session cookie based on that session
if ((session != null) && (getContext() != null)&& getContext().getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.COOKIE)) {
// 10. 根据 sessionId 来创建一个 Cookie
Cookie cookie = ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure());
// 11. 最后在响应体中写入 cookie
response.addSessionCookieInternal(cookie);
}

if (session == null) {
return null;
}
// 12. session access 计数器 + 1
session.access();
return session;
}

public abstract class ManagerBase extends LifecycleMBeanBase implements Manager {
// Manager管理着当前Context的所有session,所以会话是被 manager 里存有的一个容器管理的。
protected Map<String, Session> sessions = new ConcurrentHashMap<>();

@Override
public Session findSession(String id) throws IOException {
if (id == null) {
return null;
}
//通过JssionId获取session
return sessions.get(id);
}

public Session createSession(String sessionId) {
// 1. 判断 单节点的 Session 个数是否超过限制
if ((maxActiveSessions >= 0) && (getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(
sm.getString("managerBase.createSession.ise"),
maxActiveSessions);
}

// Recycle or create a Session instance
// 创建一个 空的 session
// 2. 创建 Session
Session session = createEmptySession();

// Initialize the properties of the new session and return it
// 初始化空 session 的属性
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
// 3. StandardSession 最大的默认 Session 激活时间
session.setMaxInactiveInterval(this.maxInactiveInterval);
String id = sessionId;
// 若没有从 client 端读取到 jsessionId
if (id == null) {
// 4. 生成 sessionId (这里通过随机数来生成)
id = generateSessionId();
}
//这里会将session存入Map<String, Session> sessions = new ConcurrentHashMap<>();
session.setId(id);
sessionCounter++;

SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
// 5. 每次创建 Session 都会创建一个 SessionTiming, 并且 push 到 链表 sessionCreationTiming 的最后
sessionCreationTiming.add(timing);
// 6. 并且将 链表 最前面的节点删除
sessionCreationTiming.poll();
}
// 那这个 sessionCreationTiming 是什么作用呢, 其实 sessionCreationTiming 是用来统计 Session的新建及失效的频率 (好像Zookeeper 里面也有这个的统计方式)
return (session);
}

@Override
public void add(Session session) {
// 将创建的Seesion存入Map<String, Session> sessions = new ConcurrentHashMap<>();
sessions.put(session.getIdInternal(), session);
int size = getActiveSessions();
if( size > maxActive ) {
synchronized(maxActiveUpdateLock) {
if( size > maxActive ) {
maxActive = size;
}
}
}
}
}

@Override
public void setId(String id) {
setId(id, true);
}

@Override
public void setId(String id, boolean notify) {

if ((this.id != null) && (manager != null))
manager.remove(this);

this.id = id;

if (manager != null)
manager.add(this);

if (notify) {
tellNew();
}
}

tomcat8.5 提供了 4 种实现,默认使用 StandardManager,tomcat 还提供了集群会话的解决方案,但是在实际项目中很少运用。

  • StandardManager:Manager 默认实现,在内存中管理 session,宕机将导致 session 丢失;但是当调用 Lifecycle 的 start/stop 接口时,将采用 jdk 序列化保存 Session 信息,因此当 tomcat 发现某个应用的文件有变更进行 reload 操作时,这种情况下不会丢失 Session 信息
  • DeltaManager:增量 Session 管理器,用于Tomcat集群的会话管理器,某个节点变更 Session 信息都会同步到集群中的所有节点,这样可以保证 Session 信息的实时性,但是这样会带来较大的网络开销
  • BackupManager:用于 Tomcat 集群的会话管理器,与DeltaManager不同的是,某个节点变更 Session 信息的改变只会同步给集群中的另一个 backup 节点
  • PersistentManager:当会话长时间空闲时,将会把 Session 信息写入磁盘,从而限制内存中的活动会话数量;此外,它还支持容错,会定期将内存中的 Session 信息备份到磁盘。

StandardManager类图

本文的主要参考文献:

  1. 《浏览器同源政策及其规避方法》
  2. 《跨域资源共享 CORS 详解》
  3. 《浏览器的同源策略》
  4. 《Cross-Origin Resource Sharing (CORS)》
  5. Tomcat源码分析 (十)——- 彻底理解 Session机制