Skip to content

Commit 7c99e30

Browse files
committed
add bind mount restrictions
1 parent 1343e5b commit 7c99e30

6 files changed

Lines changed: 439 additions & 8 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,20 @@ If both commandline parameter and environment variable is configured for a parti
7979

8080
Use Go's regexp syntax to create the patterns for these parameters. To avoid insecure configurations, the characters ^ at the beginning and $ at the end of the string are automatically added. Note: invalid regexp results in program termination.
8181

82+
#### Setting up bind mount restrictions
83+
84+
By default, socket-proxy does not restrict bind mounts. If you want to add an additional layer of security by restricting which directories can be used as bind mount sources, you can use the `-allowbindmountfrom` parameter or the `SP_ALLOWBINDMOUNTFROM` environment variable.
85+
86+
When configured, only bind mounts from the specified directories or their subdirectories are allowed. Each directory must start with `/`. Multiple directories can be specified separated by commas.
87+
88+
For example:
89+
+ `-allowbindmountfrom=/home,/var/log` allows bind mounts from `/home`, `/var/log`, and any subdirectories like `/home/user/data` or `/var/log/app`
90+
+ `SP_ALLOWBINDMOUNTFROM="/app/data,/tmp"` allows bind mounts from `/app/data` and `/tmp` directories
91+
92+
Bind mount restrictions are applied to relevant Docker API endpoints and work with both legacy bind mount syntax (`-v /host/path:/container/path`) and modern mount syntax.
93+
94+
**Note**: This feature only restricts bind mounts. Other mount types (volumes, tmpfs, etc.) are not affected by this restriction.
95+
8296
Examples (command line):
8397
+ `'-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'` could be used for allowing access to the docker socket for Traefik v2.
8498
+ `'-allowHEAD=.*` allows all HEAD requests.
@@ -133,6 +147,7 @@ services:
133147
- '-listenip=0.0.0.0'
134148
- '-allowfrom=traefik' # allow only hostname "traefik" to connect
135149
- '-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'
150+
- '-allowbindmountfrom=/var/log,/tmp' # restrict bind mounts to specific directories
136151
- '-watchdoginterval=3600' # check once per hour for socket availability
137152
- '-stoponwatchdog' # halt program on error and let compose restart it
138153
- '-shutdowngracetime=5' # wait 5 seconds before shutting down
@@ -182,6 +197,7 @@ socket-proxy can be configured via command line parameters or via environment va
182197
| Parameter | Environment Variable | Default Value | Description |
183198
|--------------------------------|----------------------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
184199
| `-allowfrom` | `SP_ALLOWFROM` | `127.0.0.1/32` | Specifies the IP addresses or hostnames (comma-separated) of the clients or the hostname of one specific client allowed to connect to the proxy. The default value is `127.0.0.1/32`, which means only localhost is allowed. This default configuration may not be useful in most cases, but it is because of a secure-by-default design. To allow all IPv4 addresses, set `-allowfrom=0.0.0.0/0`. Alternatively, hostnames can be set, for example `-allowfrom=traefik`, or `-allowfrom=traefik,dozzle`. Please remember that socket-proxy should never be exposed to a public network, regardless of this extra security layer. |
200+
| `-allowbindmountfrom` | `SP_ALLOWBINDMOUNTFROM` | (not set) | Specifies the directories (comma-separated) that are allowed as bind mount sources. If not set, no bind mount restrictions are applied. When set, only bind mounts from the specified directories or their subdirectories are allowed. Each directory must start with `/`. For example, `-allowbindmountfrom=/home,/var/log` allows bind mounts from `/home`, `/var/log`, and any subdirectories. |
185201
| `-allowhealthcheck` | `SP_ALLOWHEALTHCHECK` | (not set/false) | If set, it allows the included health check binary to check the socket connection via TCP port 55555 (socket-proxy then listens on `127.0.0.1:55555/health`) |
186202
| `-listenip` | `SP_LISTENIP` | `127.0.0.1` | Specifies the IP address the server will bind on. Default is only the internal network. |
187203
| `-logjson` | `SP_LOGJSON` | (not set/false) | If set, it enables logging in JSON format. If unset, docker-proxy logs in plain text format. |

cmd/socket-proxy/bindmount.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"path/filepath"
11+
"strings"
12+
)
13+
14+
// mountType is the subset of github.com/docker/docker/api/types/mount.Type.
15+
type mountType string
16+
17+
const (
18+
// mountTypeBind is the type for mounting host dir.
19+
mountTypeBind mountType = "bind"
20+
)
21+
22+
type (
23+
// containerCreateRequest is the subset of github.com/docker/docker/api/types/container.CreateRequest.
24+
containerCreateRequest struct {
25+
HostConfig *containerHostConfig `json:"HostConfig,omitempty"`
26+
}
27+
// containerHostConfig is the subset of github.com/docker/docker/api/types/container.HostConfig.
28+
containerHostConfig struct {
29+
Binds []string // List of volume bindings for this container.
30+
Mounts []mountMount `json:",omitempty"` // Mounts specs used by the container.
31+
}
32+
// swarmServiceSpec is the subset of github.com/docker/docker/api/types/swarm.ServiceSpec.
33+
swarmServiceSpec struct {
34+
TaskTemplate swarmTaskSpec `json:",omitempty"`
35+
}
36+
// swarmTaskSpec is the subset of github.com/docker/docker/api/types/swarm.TaskSpec.
37+
swarmTaskSpec struct {
38+
ContainerSpec *swarmContainerSpec `json:",omitempty"`
39+
}
40+
// swarmContainerSpec is the subset of github.com/docker/docker/api/types/swarm.ContainerSpec.
41+
swarmContainerSpec struct {
42+
Mounts []mountMount `json:",omitempty"`
43+
}
44+
// mountMount is the subset of github.com/docker/docker/api/types/mount.Mount.
45+
mountMount struct {
46+
Type mountType `json:",omitempty"`
47+
// Source specifies the name of the mount. Depending on mount type, this
48+
// may be a volume name or a host path, or even ignored.
49+
// Source is not supported for tmpfs (must be an empty value)
50+
Source string `json:",omitempty"`
51+
Target string `json:",omitempty"`
52+
}
53+
)
54+
55+
// checkBindMountRestrictions checks if bind mounts in the request are allowed.
56+
func checkBindMountRestrictions(r *http.Request) error {
57+
// Only check if bind mount restrictions are configured
58+
if len(cfg.AllowBindMountFrom) == 0 {
59+
return nil
60+
}
61+
62+
if r.Method != http.MethodPost {
63+
return nil
64+
}
65+
66+
// Check different API endpoints that can use bind mounts
67+
pathParts := strings.Split(r.URL.Path, "/")
68+
switch {
69+
case len(pathParts) >= 4 && pathParts[2] == "containers" && pathParts[3] == "create":
70+
// Container creation: /vX.xx/containers/create
71+
return checkContainer(r)
72+
case len(pathParts) >= 5 && pathParts[2] == "containers" && pathParts[4] == "update":
73+
// Container update: /vX.xx/containers/{id}/update
74+
return checkContainer(r)
75+
case len(pathParts) >= 4 && pathParts[2] == "services" && pathParts[3] == "create":
76+
// Service creation: /vX.xx/services/create
77+
return checkService(r)
78+
case len(pathParts) >= 5 && pathParts[2] == "services" && pathParts[4] == "update":
79+
// Service update: /vX.xx/services/{id}/update
80+
return checkService(r)
81+
default:
82+
return nil
83+
}
84+
}
85+
86+
// checkContainer checks bind mounts in container creation requests.
87+
func checkContainer(r *http.Request) error {
88+
body, err := readAndRestoreBody(r)
89+
if err != nil {
90+
return err
91+
}
92+
93+
var req containerCreateRequest
94+
if err := json.Unmarshal(body, &req); err != nil {
95+
slog.Debug("failed to parse container request", "error", err)
96+
return nil // Don't block if we can't parse.
97+
}
98+
99+
return checkHostConfigBindMounts(req.HostConfig)
100+
}
101+
102+
// checkService checks bind mounts in service creation requests.
103+
func checkService(r *http.Request) error {
104+
body, err := readAndRestoreBody(r)
105+
if err != nil {
106+
return err
107+
}
108+
109+
var req swarmServiceSpec
110+
if err := json.Unmarshal(body, &req); err != nil {
111+
slog.Debug("failed to parse service request", "error", err)
112+
return nil // Don't block if we can't parse.
113+
}
114+
115+
if req.TaskTemplate.ContainerSpec == nil {
116+
return nil // No container spec, nothing to check.
117+
}
118+
return checkHostConfigBindMounts(&containerHostConfig{
119+
Mounts: req.TaskTemplate.ContainerSpec.Mounts,
120+
})
121+
}
122+
123+
// checkHostConfigBindMounts checks bind mounts in HostConfig.
124+
func checkHostConfigBindMounts(hostConfig *containerHostConfig) error {
125+
if hostConfig == nil {
126+
return nil // No HostConfig, nothing to check
127+
}
128+
129+
// Check legacy Binds field
130+
for _, bind := range hostConfig.Binds {
131+
if err := validateBindMount(bind); err != nil {
132+
return err
133+
}
134+
}
135+
136+
// Check modern Mounts field
137+
for _, mountItem := range hostConfig.Mounts {
138+
if mountItem.Type == mountTypeBind {
139+
if err := validateBindMountSource(mountItem.Source); err != nil {
140+
return err
141+
}
142+
}
143+
}
144+
145+
return nil
146+
}
147+
148+
// validateBindMount validates a bind mount string in the format "source:target:options".
149+
func validateBindMount(bind string) error {
150+
parts := strings.Split(bind, ":")
151+
if len(parts) < 2 {
152+
return fmt.Errorf("invalid bind mount format: %s", bind)
153+
}
154+
return validateBindMountSource(parts[0])
155+
}
156+
157+
// validateBindMountSource checks if the source directory is allowed.
158+
func validateBindMountSource(source string) error {
159+
// Skip if source is not an absolute path (i.e. bind mount).
160+
if !strings.HasPrefix(source, "/") {
161+
return nil
162+
}
163+
164+
source = filepath.Clean(source) // Clean the path to resolve .. and . components.
165+
for _, allowedDir := range cfg.AllowBindMountFrom {
166+
if allowedDir == "/" || source == allowedDir || strings.HasPrefix(source, allowedDir+"/") {
167+
return nil
168+
}
169+
}
170+
171+
return fmt.Errorf("bind mount source directory not allowed: %s", source)
172+
}
173+
174+
// readAndRestoreBody reads the request body and restores it for further processing.
175+
func readAndRestoreBody(r *http.Request) ([]byte, error) {
176+
body, err := io.ReadAll(r.Body)
177+
if err != nil {
178+
return nil, fmt.Errorf("failed to read request body: %w", err)
179+
}
180+
181+
// Restore the body for further processing
182+
r.Body = io.NopCloser(bytes.NewBuffer(body))
183+
return body, nil
184+
}

0 commit comments

Comments
 (0)