饼干的结构解析

本篇 blog 介绍了 Cookie 的结构,以及 Cookie 的各个属性的作用与关联,之后可通过一个 flask 服务来简单实践。

前置环境

  • python
  • flask
  • 一个浏览器
  • 一个 http 请求工具,如 curl
属性 作用
Domain 标记 cookie 的域,会对比请求中 cookie domain 属性与目标服务器的域名比较,一致或者为子域才会进行后续 path 匹配
Path cookie 作用的 URL 路径
Expires cookie 过期时间
Max-Age cookie 过期时间,单位为秒
Secure cookie 只能通过 https 传输,该属性只能在 https 站点设置
HttpOnly cookie 只能通过 http 传输,不能通过 js 访问
SameSite cookie 只能在同站点下使用,防止跨站攻击
Name cookie 名称
Value cookie 值如果值为Unicode字符,需要为字符编码。如果为二进制数据,则需要使用BASE64编码
Priority cookie 优先级,值为 Low、Medium、High,当 cookie 超量后优先级低的可能不会被发送

cookie 属性

生命周期

  • Expires
    格式为 http-date GMT 格式,如 Fri, 01 Dec 2023 12:01:00 GMT
    用于强制删除 cookie,可通过将 expires 设置为过去的时间来实现。
  • Max-Age
    为 cookie 过期时间,单位为秒

Expires 和 Max-Age 二者只能存在一个,如果同时存在,优先使用 Max-Age。
未设置过期时间的 cookie 称为 session-cookie,浏览器会在会话结束时删除该 cookie,会话何时结束则取决于浏览器的定义。

GMT
格林尼治标准时间。在 HTTP 协议中,时间都是用格林尼治标准时间来表示的,而不是本地时间

cookie 的作用范围由 DomainPath 共同决定,浏览器会先检测请求域名和 Domain 是否匹配,如果匹配则再检测 Path 是否匹配。

  • Domain
    如当前响应中 set-cookie 设置了 Domain=nikunokoya.com 则访问子域名 vps.nikunokoya.com 时会携带该 cookie,如果为设置 Domain 则默认会将设置该 cookie 的服务器作为 Domain
  • Path
    Path 为 cookie 的作用路径,如 Path=/index/ 则访问 nikunokoya.com/index/a/b/c 时会携带该 cookie,如果不设置 Path 则默认为 /,即所有路径都会携带该 cookie。

SameSite

用于判断该 cookie 是否应该与跨站请求一起发送,以防止跨站请求伪造攻击(CSRF),SameSite 有三种取值:

  • Strict
    最严格模式,会禁止跨域请求携带该 cookie,例如一些修改密码或购物的服务请求,需要使用该模式保证服务的处理符合安全预期,而不是从某个邮件链接跳转过来并携带 cookie 造成安全隐患。
  • Lax
    会允许一部分跨域请求携带该 cookie,比如从在一个网站浏览另一个网站的图片时并不会携带 cookie,而跳转到该网站是会携带 cookie。 chrome 浏览器在未设置 SameSite 时默认为 Lax 模式。
  • None 允许所有跨站请求携带该 cookie,但是需要同时设置 Secure 属性,即只能通过 https 传输。
CSRF/origin/site
  • Same-Origin
    请求的协议、域名、端口号任意一个不同都会被认为是跨域请求。
    1. 比如从 https://www.a.com:443 发起一个请求到 https://www.b.a.com:443,这个请求就是跨域请求。
    2. https://www.a.com:443https://www.a.com:80 也是跨域请求。
  • Same-Site
    判断是否跨站的标准更加宽松,会根据 eTLD+1 是否相同并且协议相同来判断是否跨站。 https://vps.nikunokoya.com:443https://www.nikunokoya.com:443 为同站,a.comb.com 为跨站。
  • CSRF
    比如用户在 a.com 登录了账号,然后在不退出 a.com 的情况下访问了 b.com,此时 b.com 可以通过 a.com 的 cookie 伪造成用户,向 a.com 发起请求。
  • eTLD
    有效顶级域名,公共后缀列表可在 publicsuffix.org 查看。

前缀语义

cookie 前缀语义用于通知浏览器该 cookie 的使用场景,浏览器会根据前缀判断是否应该发送该 cookie。

__Host- 前缀

__Host- 开头的 cookie,表示该 cookie 必须与 SecurePath=/ 属性一起使用,并且不能设置 Domain 属性。该前缀检查最严格,cookie 不会发送给任何子域,只会发送给当前域名的不同 path

__Secure- 前缀

当 cookie name 以 __Secure- 开头时,表示该 cookie 必须与 Secure 属性一起使用。当需要向不同的子域名发送 cookie 时,可以使用该前缀,但是需要注意 Domain 属性的设置。

当需要跨域携带 cookie 时需要设置 third-party cookie,三方 cookie 通常用于个性化推荐,广告投放等场景,记录用户访问习惯,但是也会造成用户隐私泄露。
三方 cookie 通常需要调用第三方的 cookie 服务来设置。

服务端和浏览器允许跨域请求

当发送 fetch 请求时如果是跨域请求,浏览器会在请求头中添加 Origin 字段标记请求来源,后端服务中需要在响应 Header 中设置 Access-Control-Allow-Origin 字段来允许跨域请求,如果设置为 * 则表示允许所有跨域请求,如果设置为 null 则表示不允许跨域请求。

w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")

同时在前段的 fetch 请求中需要设置 credentials 字段为 include,表示允许跨域请求携带 cookie。

  • credentials=include
    表示允许跨域请求携带 cookie,但是需要设置 Access-Control-Allow-Origin 字段必须为当前请求的源,不能为 *
  • credentials=same-origin
    同源时发送 cookie。
  • credentials=omit 表示不允许跨域请求携带 cookie。

通过代理

请求先发送给代理插件(此时未发生浏览器跨域),再由代理服务发送跨域请求到目标服务,目标服务响应后再由代理服务返回给浏览器。

注意
跨域仅发生在浏览器中,如果是服务端发起的请求则不会发生跨域。

demo

目录结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
.
├── cookies
├── flask_app.py
├── mycookies
├── npx_server
│   ├── index.html
│   ├── index.js
│   ├── node_modules
│   ├── package.json
│   └── package-lock.json
├── static
│   └── index.js
└── templates
    └── index.html

flask 服务

以下用 flask 起了一个简易的服务,当访问本地的 /get-cookie/ 时会设置一个名为 id 的 cookie 并存储在浏览器中。
同时通过 CORS 设置允许跨域请求,当访问 /api/auth/ 时会检测 cookie 中的 id 是否为 123,如果是则返回一个 json,否则返回一个错误信息。

 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
from flask import Flask, make_response, request, jsonify, render_template
from flask_cors import CORS

app = Flask(__name__)
CORS(app=app, supports_credentials=True)


@app.route("/", methods=["GET"])
def index():
    return render_template("index.html")


@app.route("/get-cookie/", methods=["GET"])
def get_cookie():
    response = make_response("my cookie: ")
    response.set_cookie("id", "123", path="/api/auth", max_age=600, httponly=True)
    return response


@app.route("/about/", methods=["GET"])
def about():
    print(request.cookies)
    return "about"


@app.route("/contact/", methods=["GET"])
def contact():
    print(request.cookies)
    return "contact"


@app.route("/api/auth/", methods=["GET"])
def auth():
    if request.cookies["id"] == "123":
        res = [{"name": "niku", "id": 1}, {"name": "xx", "id": 2}]
        return jsonify(res)
    return jsonify(msg="Ops!")

启动服务:

1
FLASK_ENV=development FLASK_APP=flask_app.py flask run

curl -I http://127.0.0.1:5000/get-cookie/ --cookie cookies

1
2
3
4
5
6
7
HTTP/1.1 200 OK
Server: Werkzeug/3.0.1 Python/3.9.7
Date: Mon, 04 Dec 2023 09:11:22 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 11
Set-Cookie: id=123; Expires=Mon, 04 Dec 2023 09:21:22 GMT; Max-Age=600; HttpOnly; Path=/api/auth
Connection: close

cookie 传递作用域可在浏览器的开发者工具中验证,如 chrome 的开发者工具中的 Application -> Storage -> Cookies

html 和 js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<button>FETCH</button>
</body>
<script src="{{ url_for('static', filename='index.js') }}"></script>
</html>

发送 fetch 请求设置 cookie:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const button = document.getElementsByTagName("button")[0];

button.addEventListener("click", function() {
  getACookie().then(() => getData());
});

function getACookie() {
  return fetch("/get-cookie/").then(response => {
    // make sure to check response.ok in the real world!
    return Promise.resolve("All good, fetch the data");
  });
}

function getData() {
  fetch("/api/auth/")
    .then(response => {
      // make sure to check response.ok in the real world!
      return response.json();
    })
    .then(json => console.log(json));
}

npx serve 启动另一个不同源的服务

在另一个目录下将下面 index.html 通过 npx serve 启动另一个服务 http://localhost:3000/,在该服务下发送跨域请求到 flask 服务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<button>FETCH</button>
</body>
<script src="index.js"></script>
</html>

注意这里需要在 fetch 请求中配置 credentials:"include"

 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
const button = document.getElementsByTagName("button")[0];

button.addEventListener("click", function() {
  getACookie().then(() => getData());
});

function getACookie() {
  return fetch("http://localhost:5000/get-cookie/", {
    credentials: "include"
  
  }).then(response => {
    // make sure to check response.ok in the real world!
    return Promise.resolve("All good, fetch the data");
  });
}

function getData() {
  fetch("http://localhost:5000/api/auth/", {
    credentials: "include"
  
  })
    .then(response => {
      // make sure to check response.ok in the real world!
      return response.json();
    })
    .then(json => console.log(json));
}

0%