Skip to content

Commit 0ae3560

Browse files
authored
fix: mcp SSE hardening (#587)
1 parent e4e9dc2 commit 0ae3560

File tree

10 files changed

+631
-49
lines changed

10 files changed

+631
-49
lines changed

cmd/server/mcp.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ var (
2222
registerVetSQLQueryTool bool
2323
vetSQLQueryToolDBPath string
2424
registerPackageRegistryTool bool
25+
sseServerAllowedOrigins []string
26+
sseServerAllowedHosts []string
2527
)
2628

2729
func newMcpServerCommand() *cobra.Command {
@@ -42,6 +44,19 @@ func newMcpServerCommand() *cobra.Command {
4244
cmd.Flags().StringVar(&mcpServerSseServerAddr, "sse-server-addr", "localhost:9988", "The address to listen for SSE connections")
4345
cmd.Flags().StringVar(&mcpServerServerType, "server-type", "stdio", "The type of server to start (stdio, sse)")
4446

47+
cmd.Flags().StringSliceVar(
48+
&sseServerAllowedOrigins,
49+
"sse-allowed-origins",
50+
nil,
51+
"List of allowed origin prefixes for SSE connections. By default, we allow http://localhost:, http://127.0.0.1: and https://localhost:.",
52+
)
53+
cmd.Flags().StringSliceVar(
54+
&sseServerAllowedHosts,
55+
"sse-allowed-hosts",
56+
nil,
57+
"List of allowed hosts for SSE connections. By default, we allow localhost:9988, 127.0.0.1:9988 and [::1]:9988.",
58+
)
59+
4560
// We allow skipping default tools to allow for custom tools to be registered when the server starts.
4661
// This is useful for agents to avoid unnecessary tool registration.
4762
cmd.Flags().BoolVar(&skipDefaultTools, "skip-default-tools", false, "Skip registering default tools")
@@ -75,7 +90,23 @@ func startMcpServer() error {
7590
case "stdio":
7691
mcpSrv, err = server.NewMcpServerWithStdioTransport(server.DefaultMcpServerConfig())
7792
case "sse":
78-
mcpSrv, err = server.NewMcpServerWithSseTransport(server.DefaultMcpServerConfig())
93+
config := server.DefaultMcpServerConfig()
94+
95+
// Override with user supplied config
96+
config.SseServerAddr = mcpServerSseServerAddr
97+
98+
// override origins and hosts defaults only if user explicitly set them.
99+
// When explicitly passed as cmd line args, cobra parses
100+
// --sse-allowed-hosts='' as empty slice. Otherwise if not provided,
101+
// sse-allowed-hosts will be nil.
102+
if sseServerAllowedOrigins != nil {
103+
config.SseServerAllowedOriginsPrefix = sseServerAllowedOrigins
104+
}
105+
if sseServerAllowedHosts != nil {
106+
config.SseServerAllowedHosts = sseServerAllowedHosts
107+
}
108+
109+
mcpSrv, err = server.NewMcpServerWithSseTransport(config)
79110
default:
80111
return fmt.Errorf("invalid server type: %s", mcpServerServerType)
81112
}

docs/mcp.md

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,61 @@ The SSE (Server-Sent Events) transport supports:
4242

4343
The SSE endpoint returns appropriate headers for HEAD requests without a body, allowing tools to verify endpoint availability and capabilities.
4444

45+
### Security: Host and Origin Guards
46+
47+
For SSE, the server enforces simple, user-configurable guards to reduce the risk
48+
of unauthorized cross-origin access and DNS rebinding attacks.
49+
50+
- **Host guard**: Only allows connections whose `Host` header matches an allowed
51+
host list.
52+
- **Origin guard**: For browser requests, only allows requests whose `Origin`
53+
starts with an allowed prefix.
54+
55+
These checks are on by default with sensible localhost defaults, and you can
56+
customize them with flags when starting the server.
57+
58+
#### Defaults
59+
60+
- **Allowed hosts**: `localhost:9988`, `127.0.0.1:9988`, `[::1]:9988`
61+
- **Allowed origin prefixes**: `http://localhost:`, `http://127.0.0.1:`, `https://localhost:`
62+
63+
Requests that fail the host check are rejected with status `403`, and requests
64+
that fail the origin check are rejected with status `403`.
65+
66+
#### Customize allowed hosts and origins
67+
68+
You can override the defaults using the following flags:
69+
70+
```bash
71+
vet server mcp \
72+
--server-type sse \
73+
--sse-allowed-hosts "localhost:8080,127.0.0.1:8080" \
74+
--sse-allowed-origins "http://localhost:,https://localhost:"
75+
```
76+
77+
If you are running behind a proxy or using a different port, set both lists to
78+
match your environment. For example, when exposing SSE on port 3001:
79+
80+
```bash
81+
vet server mcp \
82+
--server-type sse \
83+
--sse-allowed-hosts "localhost:3001,127.0.0.1:3001" \
84+
--sse-allowed-origins "http://localhost:,http://127.0.0.1:,https://localhost:"
85+
```
86+
87+
With Docker, append the same flags to the container command:
88+
89+
```bash
90+
docker run --rm -i ghcr.io/safedep/vet:latest \
91+
server mcp \
92+
--server-type sse \
93+
--sse-allowed-hosts "localhost:9988,127.0.0.1:9988" \
94+
--sse-allowed-origins "http://localhost:,http://127.0.0.1:,https://localhost:"
95+
```
96+
97+
Tip: Non-browser clients may omit the `Origin` header. Those requests are
98+
allowed as long as the host guard passes.
99+
45100
## Configure MCP Client
46101

47102
> **Note:** The example below uses pre-build docker image. You can build your own by running
@@ -146,7 +201,7 @@ Add `vet-mcp` server to `.vscode/mcp.json` (project specific configuration)
146201
}
147202
```
148203

149-
In order to use `vet-mcp` for all projects in Visual Studio Code, add following `mcp` setting in [Visual Studio Code User Settings](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-user-settings) (`settings.json`)
204+
In order to use `vet-mcp` for all projects in Visual Studio Code, add following `mcp` setting in [Visual Studio Code User Settings](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-user-settings) (`settings.json`)
150205

151206
```json
152207
{
@@ -170,7 +225,6 @@ In order to use `vet-mcp` for all projects in Visual Studio Code, add following
170225
}
171226
```
172227

173-
174228
Add the following to `.github/copilot-instructions.md` file:
175229

176230
```

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ require (
6969
4d63.com/gochecknoglobals v0.2.2 // indirect
7070
ariga.io/atlas v0.34.0 // indirect
7171
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.8-20240508200655-46a4cf4ba109.1 // indirect
72-
buf.build/gen/go/safedep/api/connectrpc/go v1.18.1-20250822112533-a008e1948f1d.1 // indirect
7372
cel.dev/expr v0.24.0 // indirect
7473
cloud.google.com/go v0.121.2 // indirect
7574
cloud.google.com/go/auth v0.16.1 // indirect

go.sum

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,10 @@
44
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
55
ariga.io/atlas v0.34.0 h1:4hdy+2x+xNs6Lx2anuJ/4Q7lCaqddbEj5CtRDVOBu0M=
66
ariga.io/atlas v0.34.0/go.mod h1:WJesu2UCpGQvgUh3oVP94EiRT61nNy1W/VN5g+vqP1I=
7-
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M=
8-
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
97
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.8-20240508200655-46a4cf4ba109.1 h1:7JbSS7TE2PJR4d/qRtynipwLl/CBFoTB69pX7xlhcJM=
108
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.8-20240508200655-46a4cf4ba109.1/go.mod h1:8EQ5GzyGJQ5tEIwMSxCl8RKJYsjCpAwkdcENoioXT6g=
11-
buf.build/gen/go/safedep/api/connectrpc/go v1.18.1-20250822112533-a008e1948f1d.1 h1:l2Fuy7PMz0wR8sQVQlhMnm6fxr6ZLdWeAR7NzZ5w2jI=
12-
buf.build/gen/go/safedep/api/connectrpc/go v1.18.1-20250822112533-a008e1948f1d.1/go.mod h1:W2eqH9M5zldL2cDL9xqE/HsWe2FoWw+Bwzaul95RQko=
13-
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250610075857-7cfdb61a0bfa.2 h1:ENbt9SmU2gh4YhjcFqzceJRlg80hsD28M+Oon9l752A=
14-
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250610075857-7cfdb61a0bfa.2/go.mod h1:WDOWZglnweQ4njVEJpLYYpLMx9fD+e94KbKdt8oJrxY=
159
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250819072717-b69aa2c62a0d.2 h1:A4enKVmVf69uVSG88POR59z5YE6dhATNLpL8+DmZtsg=
1610
buf.build/gen/go/safedep/api/grpc/go v1.5.1-20250819072717-b69aa2c62a0d.2/go.mod h1:Raps9oq+lWS0tdif5yUy8MS6UGc2pr6NMSrv3Jz4avM=
17-
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250705071048-7ad8e6be7c05.1 h1:4sM5O5dx0yUucJ1trjZ8Cm9IGX2loEc4cUyh3Xy+5eU=
18-
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250705071048-7ad8e6be7c05.1/go.mod h1:uR95GqsnNCRn6cTyRBte6uMJMm0rEBRxTGpakKCNL9I=
19-
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.8-20250819072717-b69aa2c62a0d.1 h1:fRdyfm5aiolcZmJuWPzbbI4cSYJlssvBZXi/BQUfMWc=
20-
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.8-20250819072717-b69aa2c62a0d.1/go.mod h1:Q5oZou54kSUyZHl4RSPY93qr3b1ssj3ZvdBAhRAdlJA=
2111
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.8-20250822112533-a008e1948f1d.1 h1:XqV9omaTxxXaI9VvS87PX4Uw6h927UycRR7SfwENSHU=
2212
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.8-20250822112533-a008e1948f1d.1/go.mod h1:Q5oZou54kSUyZHl4RSPY93qr3b1ssj3ZvdBAhRAdlJA=
2313
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
@@ -2066,8 +2056,6 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
20662056
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
20672057
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
20682058
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
2069-
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
2070-
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
20712059
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
20722060
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
20732061
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=

mcp/server/guard.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package server
2+
3+
import (
4+
"net/http"
5+
"slices"
6+
"strings"
7+
)
8+
9+
// hostGuard is a middleware that allows only the allowed hosts to access the
10+
// MCP server. nil config.SseServerAllowedHosts will use the default allowed hosts. Empty
11+
// config.SseServerAllowedHosts will block all hosts.
12+
func hostGuard(config McpServerConfig, next http.Handler) http.Handler {
13+
allowedHosts := config.SseServerAllowedHosts
14+
15+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
// contains is faster than a map lookup for small lists
17+
if !slices.Contains(allowedHosts, r.Host) {
18+
w.WriteHeader(http.StatusForbidden)
19+
return
20+
}
21+
next.ServeHTTP(w, r)
22+
})
23+
}
24+
25+
// originGuard is a middleware that allows only the allowed origins to access
26+
// the MCP server. If allowedOriginsPrefix is nil or empty, all origins will be blocked.
27+
func originGuard(config McpServerConfig, next http.Handler) http.Handler {
28+
allowedOriginsPrefix := config.SseServerAllowedOriginsPrefix
29+
30+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+
o := r.Header.Get("Origin")
32+
if o == "" {
33+
// Non-browser/same-origin fetches may omit Origin. Don't block
34+
// solely on this.
35+
next.ServeHTTP(w, r)
36+
return
37+
}
38+
39+
if !isAllowedOrigin(o, allowedOriginsPrefix) {
40+
http.Error(w, "forbidden origin", http.StatusForbidden)
41+
return
42+
}
43+
44+
next.ServeHTTP(w, r)
45+
})
46+
}
47+
48+
// isAllowedOrigin checks if the origin is in the allowed origins prefix list.
49+
func isAllowedOrigin(origin string, allowedOriginsPrefix []string) bool {
50+
for _, allowedOriginPrefix := range allowedOriginsPrefix {
51+
if strings.HasPrefix(origin, allowedOriginPrefix) {
52+
return true
53+
}
54+
}
55+
return false
56+
}

0 commit comments

Comments
 (0)