图片加载报错403,但可以单独打开图片链接——图片防盗链

#杂项
最近在写博客的时候引用了一些图片链接,但是奇怪的是,页面上的图片是没有办法正常显示的,但是我们单独打开图片链接的时候又发现他是正常的。而这个现象其实就是图片加了防盗链。

什么是防盗链?

GPT说:图片防盗链是指通过技术手段限制未经授权的外部网站直接引用自己服务器上的图片资源。其目的是防止其他网站在未经许可的情况下,直接嵌入图片链接,从而盗用带宽和服务器资源,甚至侵犯图片版权。

比方说某校微学工发的不知道从哪来的天气预报的图片:
example

看上面这张照片,我们博客里面看到的就会是类似“此图片来自微信公众平台,未经允许不可饮用”的字样,但是我们单独打开图片的时候(比如新标签页打开链接),图片就会显示正常。这其实就是给图片上了防盗链的表现

他怎么知道的?

image.png
其实也简单,我们看上面这张图片,图中可以看出,我们请求体的header中有一个referrer字段,也就是

1
https://blog.zerohzzzz.cn/2024/11/24/%E5%9B%BE%E7%89%87%E5%8A%A0%E8%BD%BD%E6%8A%A5%E9%94%99403%EF%BC%8C%E4%BD%86%E5%8F%AF%E4%BB%A5%E5%8D%95%E7%8B%AC%E6%89%93%E5%BC%80%E5%9B%BE%E7%89%87%E9%93%BE%E6%8E%A5%E9%97%AE%E9%A2%98%E8%AE%B0%E5%BD%95/

发起 HTTP 请求时,可以携带来源地址的信息,也就是 Referrer。这个 Referrer 信息是可选的,可以选择是否携带,但无法修改其具体内容,不能自定义 Referrer 的值。

服务器接收到 Referrer 后,可以基于此信息进行处理。例如,对于图片资源,服务器可以检查 Referrer 是否来自本站。如果不是,则返回 403 状态码或重定向到其他内容,以实现图片防盗链机制。出现 403 的原因通常是因为请求了其他服务器的资源,但请求中带上了自身的 Referrer 信息,被对方服务器识别并拦截,因此返回了 403 错误。

如何实现?

直接判断 Referrer

对于后端来说,判断这个东西其实是相对简单的。我们可以直接用Nginx来实现:

1
2
3
4
5
6
location ~* \.(gif|jpg|jpeg|png)$ {
valid_referers none blocked yourdomain.com *.yourdomain.com;
if ($invalid_referer) {
return 403;
}
}

当然我们还有很多其他的方法,也不一定要使用判断 Referrer 的方法。

图片动态加载

我们可以实现一个图片动态加载服务,访问图片时并不暴露图片的实际路径,而是通过动态生成的 URL 提供访问。比方说下面我们使用 Go 来实现:

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
package main

import (
"io"
"log"
"net/http"
"os"
)

func main() {
http.HandleFunc("/image", imageHandler)

log.Println("Starting server on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}

func imageHandler(w http.ResponseWriter, r *http.Request) {
// 获取图片的名称,例如 /image?name=cat.jpg
imageName := r.URL.Query().Get("name")
if imageName == "" {
http.Error(w, "Image name not provided", http.StatusBadRequest)
return
}

// 拼接图片的实际路径
imagePath := "./images/" + imageName

// 打开图片文件
file, err := os.Open(imagePath)
if err != nil {
http.Error(w, "Image not found", http.StatusNotFound)
return
}
defer file.Close()

// 设置响应头,声明返回的内容是图片
w.Header().Set("Content-Type", "image/jpeg")

// 将图片数据写入响应
_, err = io.Copy(w, file)
if err != nil {
http.Error(w, "Error serving the image", http.StatusInternalServerError)
return
}
}

目录结构如下:

1
2
3
4
5
.
├── images
│ ├── cat.jpg
│ ├── dog.jpg
├── main.go

但是这个方法会有一些缺陷,因为后端会多一个 I/O 操作,大量并发请求可能导致服务器压力增加,因此并不是一个很好的办法。

签名 URL

还有一种办法就是使用签名 URL 的办法,在生成图片 URL 时,附加一个签名(通常是基于哈希算法的加密字符串),这个签名与时间戳等信息结合,用于验证图片访问的合法性。签名 URL 一旦过期或被篡改,访问请求会被拒绝。验证逻辑由 CDN 或后端简单验证就行,这样性能会更好。

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
package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"time"
)

// 定义密钥,用于生成和验证签名
const secretKey = "mysecretkey123"

// 图片文件存储路径
const imagePath = "./images/"

// 生成签名 URL
func generateSignedURL(imageName string, validDuration time.Duration) string {
// 过期时间戳
expiry := time.Now().Add(validDuration).Unix()

// 生成签名字符串
signature := createSignature(imageName, expiry)

// 生成签名 URL
return fmt.Sprintf("/image?name=%s&expiry=%d&signature=%s", imageName, expiry, signature)
}

// 生成签名
func createSignature(imageName string, expiry int64) string {
data := fmt.Sprintf("%s|%d", imageName, expiry)
h := hmac.New(sha256.New, []byte(secretKey))
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}

// 验证签名
func validateSignature(imageName string, expiry int64, signature string) bool {
// 检查是否过期
if expiry < time.Now().Unix() {
return false
}

// 重新生成签名
expectedSignature := createSignature(imageName, expiry)

// 验证签名是否一致
return hmac.Equal([]byte(expectedSignature), []byte(signature))
}

// 图片处理函数
func imageHandler(w http.ResponseWriter, r *http.Request) {
// 获取请求参数
imageName := r.URL.Query().Get("name")
expiryStr := r.URL.Query().Get("expiry")
signature := r.URL.Query().Get("signature")

// 验证参数完整性
if imageName == "" || expiryStr == "" || signature == "" {
http.Error(w, "Invalid request parameters", http.StatusBadRequest)
return
}

// 转换过期时间戳
expiry, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil {
http.Error(w, "Invalid expiry parameter", http.StatusBadRequest)
return
}

// 验证签名
if !validateSignature(imageName, expiry, signature) {
http.Error(w, "Invalid or expired signature", http.StatusForbidden)
return
}

// 拼接图片路径
filePath := imagePath + imageName

// 打开图片文件
file, err := http.Dir(imagePath).Open(imageName)
if err != nil {
http.Error(w, "Image not found", http.StatusNotFound)
return
}
defer file.Close()

// 设置响应头并返回图片内容
http.ServeFile(w, r, filePath)
}

func main() {
// 生成示例签名 URL
imageName := "cat.jpg"
validDuration := 10 * time.Minute
signedURL := generateSignedURL(imageName, validDuration)
fmt.Printf("Generated signed URL: http://localhost:8080%s\n", signedURL)

// 注册路由
http.HandleFunc("/image", imageHandler)

// 启动服务器
fmt.Println("Starting server on :8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Error starting server:", err)
}
}


图片加载报错403,但可以单独打开图片链接——图片防盗链
http://zerohzzzz.github.io/2024/11/24/图片加载报错403,但可以单独打开图片链接——图片防盗链/
Author
ZeroHzzzz
Posted on
November 24, 2024
Licensed under