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