Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eb5cc7e
implement per-container allowlists
amanda-wee Oct 4, 2025
5b313a3
Add logging for allowlists
amanda-wee Oct 4, 2025
74330cc
Fix Docker label prefix typo
amanda-wee Oct 4, 2025
bb56d83
Update allowlists by subscribing to Docker events stream
amanda-wee Oct 4, 2025
7d5261e
Move non-default allowlist setup to after logger setup
amanda-wee Oct 5, 2025
4b8fe0e
Refactor proxy container networks to socket proxy networks in allowli…
amanda-wee Oct 5, 2025
b5c75c6
Refactor and comment config functions
amanda-wee Oct 5, 2025
7840bb3
Update readme for per-container allowlists
amanda-wee Oct 5, 2025
0f7ffd6
Add more documentation for per-container allowlists
amanda-wee Oct 5, 2025
d1d7b9f
Only setup non-default allowlists for TCP proxy and when socket proxy…
amanda-wee Oct 6, 2025
ed03073
Handle mutex more carefully
amanda-wee Oct 6, 2025
036b964
Handle Docker restart events
amanda-wee Oct 6, 2025
ceab24a
Retrieve container restart events from Docker API
amanda-wee Oct 6, 2025
34536a1
Loop over Docker event stream
amanda-wee Oct 6, 2025
17356cc
Improve container event handling
amanda-wee Oct 6, 2025
eaec378
Improve adding of allowlist
amanda-wee Oct 6, 2025
1eefa2d
Refactor HTTP request handling for allowlists
amanda-wee Oct 7, 2025
dbbed19
Do not export AllowListRegistry networks field
amanda-wee Oct 8, 2025
323e5c5
Eliminate unnecessary method name extraction
amanda-wee Oct 9, 2025
24ea509
Store AllowList values directly as they should be immutable once set
amanda-wee Oct 9, 2025
4db7757
Cancel context for Docker event stream
amanda-wee Oct 10, 2025
8ce7d45
Add retry logic for socket proxy container summary
amanda-wee Oct 10, 2025
cd5c813
Print container short IDs to identify them
amanda-wee Oct 20, 2025
71a8366
Print allowed bind mounts for per-container allowlists
amanda-wee Oct 20, 2025
a7c7a74
Integrate Docker SDK to remove dependency
amanda-wee Oct 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---
Parts of this project, specifically the file cmd/internal/bindmount.go,
Parts of this project, specifically the file cmd/socket-proxy/bindmount.go and
the files in the internal/docker and internal/go-connections folders,
contain source code licensed under the Apache License 2.0. See the comments
in that file for details.
in the applicable files for details.
The rest of the project is licensed under the MIT License.

Apache License
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ As an additional benefit, socket-proxy can be used to examine the API calls of t
The advantage over other solutions is the very slim container image (from-scratch-image) without any external dependencies (no OS, no packages, just the Go standard library).
It is designed with security in mind, so there are secure defaults and an additional security layer (IP address-based access control) compared to most other solutions.

The allowlist is configured for each HTTP method separately using the Go regexp syntax, allowing fine-grained control over the allowed HTTP methods.
The allowlist is configured for each HTTP method separately using the Go regexp syntax, allowing fine-grained control over the allowed HTTP methods. In bridge network mode, each container that uses socket-proxy can be configured with its own allowlist.

The source code is available on [GitHub: wollomatic/socket-proxy](https://github.com/wollomatic/socket-proxy)

Expand Down Expand Up @@ -110,6 +110,27 @@ Bind mount restrictions are applied to relevant Docker API endpoints and work wi

**Note**: This feature only restricts bind mounts. Other mount types (volumes, tmpfs, etc.) are not affected by this restriction.

#### Setting up per-container allowlists

Allowlists for both requests and bind mount restrictions can be specified for particular containers. To do this:

1. Set `-proxycontainername` or the environment variable `SP_PROXYCONTAINERNAME` to the name of the socket proxy container.
2. Make sure that each container that will use the socket proxy is in a Docker network that the socket proxy container is also in.
3. Use the same regex syntax for request allowlists and for bind mount restrictions that were discussed earlier, but for labels on each container that will use the socket proxy. Each label name will have the prefix of `socket-proxy.allow.`, with `socket-proxy.allow.bindmountfrom` for bind mount restrictions. For example:

``` compose.yaml
services:
traefik:
# [...] see github.com/wollomatic/traefik-hardened for a full example
networks:
- traefik-servicenet # this is the common traefik network
- docker-proxynet # this should be only restricted to traefik and socket-proxy
labels:
- 'socket-proxy.allow.get=.*' # allow all GET requests to socket-proxy
```

When this is used, it is not necessary to specify the container in `-allowfrom` as the presence of the allowlist labels will grant corresponding access.

### Container health check

Health checks are disabled by default. As the socket-proxy container may not be exposed to a public network, a separate health check binary is included in the container image. To activate the health check, the `-allowhealthcheck` parameter or the environment variable `SP_ALLOWHEALTHCHECK=true` must be set. Then, a health check is possible for example with the following docker-compose snippet:
Expand Down Expand Up @@ -212,6 +233,7 @@ socket-proxy can be configured via command line parameters or via environment va
| `-watchdoginterval` | `SP_WATCHDOGINTERVAL` | `0` | Check for socket availability every x seconds (disable checks, if not set or value is 0) |
| `-proxysocketendpoint` | `SP_PROXYSOCKETENDPOINT` | (not set) | Proxy to the given unix socket instead of a TCP port |
| `-proxysocketendpointfilemode` | `SP_PROXYSOCKETENDPOINTFILEMODE` | `0600` | Explicitly set the file mode for the filtered unix socket endpoint (only useful with `-proxysocketendpoint`) |
| `-proxycontainername` | `SP_PROXYCONTAINERNAME ` | (not set) | Provides the name of the socket proxy container to enable per-container allowlists specified by Docker container labels (not available with `-proxysocketendpoint`) |

### Changelog

Expand Down Expand Up @@ -240,7 +262,7 @@ socket-proxy can be configured via command line parameters or via environment va
## License
This project is licensed under the MIT License – see the [LICENSE](LICENSE) file for details.

Parts of the file `cmd/internal/bindmount.go` are licensed under the Apache 2.0 License.
Parts of the file `cmd/socket-proxy/bindmount.go` and files under the `internal/docker` and `internal/go-connections` folders are licensed under the Apache 2.0 License.
See the comments in this file and the LICENSE file for more information.

## Aknowledgements
Expand Down
41 changes: 22 additions & 19 deletions cmd/socket-proxy/bindmount.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ type (
)

// checkBindMountRestrictions checks if bind mounts in the request are allowed.
func checkBindMountRestrictions(r *http.Request) error {
func checkBindMountRestrictions(allowedBindMounts []string, r *http.Request) error {
// Only check if bind mount restrictions are configured
if len(cfg.AllowBindMountFrom) == 0 {
if len(allowedBindMounts) == 0 {
return nil
}

Expand All @@ -94,23 +94,23 @@ func checkBindMountRestrictions(r *http.Request) error {
switch {
case len(pathParts) >= 4 && pathParts[2] == "containers" && pathParts[3] == "create":
// Container creation: /vX.xx/containers/create
return checkContainer(r)
return checkContainer(allowedBindMounts, r)
case len(pathParts) >= 5 && pathParts[2] == "containers" && pathParts[4] == "update":
// Container update: /vX.xx/containers/{id}/update
return checkContainer(r)
return checkContainer(allowedBindMounts, r)
case len(pathParts) >= 4 && pathParts[2] == "services" && pathParts[3] == "create":
// Service creation: /vX.xx/services/create
return checkService(r)
return checkService(allowedBindMounts, r)
case len(pathParts) >= 5 && pathParts[2] == "services" && pathParts[4] == "update":
// Service update: /vX.xx/services/{id}/update
return checkService(r)
return checkService(allowedBindMounts, r)
default:
return nil
}
}

// checkContainer checks bind mounts in container creation requests.
func checkContainer(r *http.Request) error {
func checkContainer(allowedBindMounts []string, r *http.Request) error {
body, err := readAndRestoreBody(r)
if err != nil {
return err
Expand All @@ -122,11 +122,11 @@ func checkContainer(r *http.Request) error {
return nil // Don't block if we can't parse.
}

return checkHostConfigBindMounts(req.HostConfig)
return checkHostConfigBindMounts(allowedBindMounts, req.HostConfig)
}

// checkService checks bind mounts in service creation requests.
func checkService(r *http.Request) error {
func checkService(allowedBindMounts []string, r *http.Request) error {
body, err := readAndRestoreBody(r)
if err != nil {
return err
Expand All @@ -141,28 +141,31 @@ func checkService(r *http.Request) error {
if req.TaskTemplate.ContainerSpec == nil {
return nil // No container spec, nothing to check.
}
return checkHostConfigBindMounts(&containerHostConfig{
Mounts: req.TaskTemplate.ContainerSpec.Mounts,
})
return checkHostConfigBindMounts(
allowedBindMounts,
&containerHostConfig{
Mounts: req.TaskTemplate.ContainerSpec.Mounts,
},
)
}

// checkHostConfigBindMounts checks bind mounts in HostConfig.
func checkHostConfigBindMounts(hostConfig *containerHostConfig) error {
func checkHostConfigBindMounts(allowedBindMounts []string, hostConfig *containerHostConfig) error {
if hostConfig == nil {
return nil // No HostConfig, nothing to check
}

// Check legacy Binds field
for _, bind := range hostConfig.Binds {
if err := validateBindMount(bind); err != nil {
if err := validateBindMount(allowedBindMounts, bind); err != nil {
return err
}
}

// Check modern Mounts field
for _, mountItem := range hostConfig.Mounts {
if mountItem.Type == mountTypeBind {
if err := validateBindMountSource(mountItem.Source); err != nil {
if err := validateBindMountSource(allowedBindMounts, mountItem.Source); err != nil {
return err
}
}
Expand All @@ -172,23 +175,23 @@ func checkHostConfigBindMounts(hostConfig *containerHostConfig) error {
}

// validateBindMount validates a bind mount string in the format "source:target:options".
func validateBindMount(bind string) error {
func validateBindMount(allowedBindMounts []string, bind string) error {
parts := strings.Split(bind, ":")
if len(parts) < 2 {
return fmt.Errorf("invalid bind mount format: %s", bind)
}
return validateBindMountSource(parts[0])
return validateBindMountSource(allowedBindMounts, parts[0])
}

// validateBindMountSource checks if the source directory is allowed.
func validateBindMountSource(source string) error {
func validateBindMountSource(allowedBindMounts []string, source string) error {
// Skip if source is not an absolute path (i.e. bind mount).
if !strings.HasPrefix(source, "/") {
return nil
}

source = filepath.Clean(source) // Clean the path to resolve .. and . components.
for _, allowedDir := range cfg.AllowBindMountFrom {
for _, allowedDir := range allowedBindMounts {
if allowedDir == "/" || source == allowedDir || strings.HasPrefix(source, allowedDir+"/") {
return nil
}
Expand Down
25 changes: 7 additions & 18 deletions cmd/socket-proxy/bindmount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"net/http"
"runtime"
"testing"

"github.com/wollomatic/socket-proxy/internal/config"
)

func skipIfNotUnix(t *testing.T) {
Expand All @@ -21,9 +19,7 @@ func skipIfNotUnix(t *testing.T) {
func TestValidateBindMountSource(t *testing.T) {
skipIfNotUnix(t)

cfg = &config.Config{
AllowBindMountFrom: []string{"/home", "/var/log"},
}
allowedBindMounts := []string{"/home", "/var/log"}

tests := []struct {
name string
Expand All @@ -44,7 +40,7 @@ func TestValidateBindMountSource(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBindMountSource(tt.source)
err := validateBindMountSource(allowedBindMounts, tt.source)
if tt.shouldPass && err != nil {
t.Errorf("expected %s to pass, but got error: %v", tt.source, err)
}
Expand Down Expand Up @@ -83,10 +79,7 @@ func TestIsPathAllowed(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg = &config.Config{
AllowBindMountFrom: []string{tt.allowedDir},
}
err := validateBindMountSource(tt.path)
err := validateBindMountSource([]string{tt.allowedDir}, tt.path)
if (err == nil) != tt.expected {
t.Errorf("isPathAllowed(%s, %s) = %v, expected %v", tt.path, tt.allowedDir, err, tt.expected)
}
Expand All @@ -97,9 +90,7 @@ func TestIsPathAllowed(t *testing.T) {
func TestValidateBindMount(t *testing.T) {
skipIfNotUnix(t)

cfg = &config.Config{
AllowBindMountFrom: []string{"/home", "/var/log"},
}
allowedBindMounts := []string{"/home", "/var/log"}

tests := []struct {
name string
Expand All @@ -115,7 +106,7 @@ func TestValidateBindMount(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBindMount(tt.bind)
err := validateBindMount(allowedBindMounts, tt.bind)
if tt.shouldPass && err != nil {
t.Errorf("expected %s to pass, but got error: %v", tt.bind, err)
}
Expand All @@ -129,9 +120,7 @@ func TestValidateBindMount(t *testing.T) {
func TestCheckBindMountRestrictions(t *testing.T) {
skipIfNotUnix(t)

cfg = &config.Config{
AllowBindMountFrom: []string{"/home"},
}
allowedBindMounts := []string{"/home"}

tests := []struct {
name string
Expand Down Expand Up @@ -212,7 +201,7 @@ func TestCheckBindMountRestrictions(t *testing.T) {
t.Fatalf("failed to create request: %v", err)
}

err = checkBindMountRestrictions(req)
err = checkBindMountRestrictions(allowedBindMounts, req)
if tt.shouldPass && err != nil {
t.Errorf("expected request to pass, but got error: %v", err)
}
Expand Down
58 changes: 40 additions & 18 deletions cmd/socket-proxy/handlehttprequest.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,21 @@ import (
"log/slog"
"net"
"net/http"

"github.com/wollomatic/socket-proxy/internal/config"
)

// handleHTTPRequest checks if the request is allowed and sends it to the proxy.
// Otherwise, it returns a "405 Method Not Allowed" or a "403 Forbidden" error.
// In case of an error, it returns a 500 Internal Server Error.
func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket
allowedIP, err := isAllowedClient(r.RemoteAddr)
if err != nil {
slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr)
}
if !allowedIP {
communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden)
return
}
allowList, ok := determineAllowList(r)
if !ok {
communicateBlockedRequest(w, r, "forbidden IP", http.StatusForbidden)
return
}

// check if the request is allowed
allowed, exists := cfg.AllowedRequests[r.Method]
allowed, exists := allowList.AllowedRequests[r.Method]
if !exists { // method not in map -> not allowed
communicateBlockedRequest(w, r, "method not allowed", http.StatusMethodNotAllowed)
return
Expand All @@ -34,7 +30,7 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
}

// check bind mount restrictions
if err := checkBindMountRestrictions(r); err != nil {
if err := checkBindMountRestrictions(allowList.AllowedBindMounts, r); err != nil {
communicateBlockedRequest(w, r, "bind mount restriction: "+err.Error(), http.StatusForbidden)
return
}
Expand All @@ -44,14 +40,40 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) {
socketProxy.ServeHTTP(w, r) // proxy the request
}

// return the relevant allowlist
func determineAllowList(r *http.Request) (config.AllowList, bool) {
if cfg.ProxySocketEndpoint == "" { // do not perform this check if we proxy to a unix socket
// Get the client IP address from the remote address string
clientIPStr, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
slog.Warn("cannot get valid IP address from request", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr)
return config.AllowList{}, false
}

// If applicable, get the non-default allowlist corresponding to the client IP address
if cfg.ProxyContainerName != "" {
allowList, found := cfg.AllowLists.FindByIP(clientIPStr)
if found {
return allowList, true
}
}

// Check if client is allowed for the default allowlist:
allowedIP, err := isAllowedClient(clientIPStr)
if err != nil {
slog.Warn("cannot get valid IP address for client allowlist check", "reason", err, "method", r.Method, "URL", r.URL, "client", r.RemoteAddr)
}
if !allowedIP {
return config.AllowList{}, false
}
}

return cfg.AllowLists.Default, true
}

// isAllowedClient checks if the given remote address is allowed to connect to the proxy.
// The IP address is extracted from a RemoteAddr string (the part before the colon).
func isAllowedClient(remoteAddr string) (bool, error) {
// Get the client IP address from the remote address string
clientIPStr, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return false, err
}
func isAllowedClient(clientIPStr string) (bool, error) {
// Parse the IP address
clientIP := net.ParseIP(clientIPStr)
if clientIP == nil {
Expand Down
Loading