diff --git a/README.md b/README.md index 5eaa8bf..78bac5a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The source code is available on [GitHub: wollomatic/socket-proxy](https://github > [!NOTE] > Starting with version 1.6.0, the socket-proxy container image is also available on GHCR. +> Starting with version todo, the socket-proxy can set multiple times -allow* in params or environment of docker labels ## Getting Started @@ -93,10 +94,12 @@ Use Go's regexp syntax to create the patterns for these parameters. To avoid ins Examples (command-line): + `'-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'` could be used for allowing access to the docker socket for Traefik v2. + `'-allowHEAD=.*'` allows all HEAD requests. ++ `'-allowGET=/version' '-allowGET=/_ping'` allow use `GET` multiple times Examples (env variables): + `'SP_ALLOW_GET="/v1\..{1,2}/(version|containers/.*|events.*)"'` could be used for allowing access to the docker socket for Traefik v2. + `'SP_ALLOW_HEAD=".*"'` allows all HEAD requests. ++ `'SP_ALLOW_GET="/version" SP_ALLOW_GET_2=/_ping'` allow use `GET` multiple times For more information, refer to the [Go regexp documentation](https://golang.org/pkg/regexp/syntax/). @@ -135,6 +138,8 @@ services: - docker-proxynet # this should be only restricted to traefik and socket-proxy labels: - 'socket-proxy.allow.get=.*' # allow all GET requests to socket-proxy + - 'socket-proxy.allow.head=/version' # HEAD `/version` requests to socket-proxy + - 'socket-proxy.allow.head.1=/exec' # another HEAD `exec` 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. @@ -227,7 +232,7 @@ To log the API calls of the client application, set the log level to `DEBUG` and socket-proxy can be configured via command-line parameters or via environment variables. If both command-line parameters and environment variables are set, the environment variable will be ignored. | Parameter | Environment Variable | Default Value | Description | -|--------------------------------|----------------------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ------------------------------ | -------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `-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. | | `-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. | | `-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`) | @@ -235,7 +240,7 @@ socket-proxy can be configured via command-line parameters or via environment va | `-logjson` | `SP_LOGJSON` | (not set/false) | If set, it enables logging in JSON format. If unset, socket-proxy logs in plain text format. | | `-loglevel` | `SP_LOGLEVEL` | `INFO` | Sets the log level. Accepted values are: `DEBUG`, `INFO`, `WARN`, `ERROR`. | | `-proxyport` | `SP_PROXYPORT` | `2375` | Defines the TCP port the proxy listens to. | -| `-shutdowngracetime` | `SP_SHUTDOWNGRACETIME` | `10` | Defines the time in seconds to wait before forcing the shutdown after SIGTERM or SIGINT (socket-proxy first tries to gracefully shut down the TCP server) | | +| `-shutdowngracetime` | `SP_SHUTDOWNGRACETIME` | `10` | Defines the time in seconds to wait before forcing the shutdown after SIGTERM or SIGINT (socket-proxy first tries to gracefully shut down the TCP server) | | `-socketpath` | `SP_SOCKETPATH` | `/var/run/docker.sock` | Specifies the UNIX socket path to connect to. By default, it connects to the Docker daemon socket. | | `-stoponwatchdog` | `SP_STOPONWATCHDOG` | (not set/false) | If set, socket-proxy will be stopped if the watchdog detects that the unix socket is not available. | | `-watchdoginterval` | `SP_WATCHDOGINTERVAL` | `0` | Check for socket availability every x seconds (disable checks, if not set or value is 0) | diff --git a/cmd/socket-proxy/handlehttprequest.go b/cmd/socket-proxy/handlehttprequest.go index 7bf1092..4b02612 100644 --- a/cmd/socket-proxy/handlehttprequest.go +++ b/cmd/socket-proxy/handlehttprequest.go @@ -5,6 +5,7 @@ import ( "log/slog" "net" "net/http" + "regexp" "github.com/wollomatic/socket-proxy/internal/config" ) @@ -24,7 +25,7 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { communicateBlockedRequest(w, r, "method not allowed", http.StatusMethodNotAllowed) return } - if !allowed.MatchString(r.URL.Path) { // path does not match regex -> not allowed + if !matchURL(allowed, r.URL.Path) { // path does not match regex -> not allowed communicateBlockedRequest(w, r, "path not allowed", http.StatusForbidden) return } @@ -40,6 +41,15 @@ func handleHTTPRequest(w http.ResponseWriter, r *http.Request) { socketProxy.ServeHTTP(w, r) // proxy the request } +func matchURL(allowedURIs []*regexp.Regexp, requestURI string) bool { + for _, allowedURI := range allowedURIs { + if allowedURI.MatchString(requestURI) { + return true + } + } + return false +} + // 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 diff --git a/internal/config/config.go b/internal/config/config.go index 9d91f69..e6c0a1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,22 +67,20 @@ type AllowListRegistry struct { } type AllowList struct { - ID string // Container ID (empty for the default allowlist) - AllowedRequests map[string]*regexp.Regexp // map of request methods to request path regex patterns (no requests allowed if empty) - AllowedBindMounts []string // list of from portion of allowed bind mounts (all bind mounts allowed if empty) + ID string // Container ID (empty for the default allowlist) + AllowedRequests map[string][]*regexp.Regexp // map of request methods to request path regex patterns (no requests allowed if empty) + AllowedBindMounts []string // list of from portion of allowed bind mounts (all bind mounts allowed if empty) } // used for list of allowed requests type methodRegex struct { - method string - regexStringFromEnv string - regexStringFromParam string + method string + regexStrings arrayParams } // mr is the allowlist of requests per http method -// default: regexStringFromEnv and regexStringFromParam are empty, so regexCompiled stays nil and the request is blocked -// if regexStringParam is set with a command line parameter, all requests matching the method and path matching the regex are allowed -// else if regexStringEnv from Environment ist checked +// default: regexStrings are empty, so regexCompiled stays nil and the request is blocked +// if regexStrings is set, all requests matching the method and path matching the regex are allowed var mr = []methodRegex{ {method: http.MethodGet}, {method: http.MethodHead}, @@ -163,9 +161,14 @@ func InitConfig() (*Config, error) { defaultProxyContainerName = val } + // multiple values per method + // like SP_ALLOW_GET_0, SP_ALLOW_GET_1, ... + allowFromEnv := getAllowFromEnv(os.Environ()) for i := range mr { - if val, ok := os.LookupEnv("SP_ALLOW_" + mr[i].method); ok && val != "" { - mr[i].regexStringFromEnv = val + if val, ok := allowFromEnv[mr[i].method]; ok && len(val) > 0 { + for _, v := range val { + mr[i].regexStrings = append(mr[i].regexStrings, param{value: v, from: fromEnv}) + } } } @@ -190,7 +193,7 @@ func InitConfig() (*Config, error) { flag.StringVar(&allowBindMountFromString, "allowbindmountfrom", defaultAllowBindMountFrom, "allowed directories for bind mounts (comma-separated)") flag.StringVar(&cfg.ProxyContainerName, "proxycontainername", defaultProxyContainerName, "socket-proxy Docker container name") for i := range mr { - flag.StringVar(&mr[i].regexStringFromParam, "allow"+mr[i].method, "", "regex for "+mr[i].method+" requests (not set means method is not allowed)") + flag.Var(&mr[i].regexStrings, "allow"+mr[i].method, "regex for "+mr[i].method+" requests (not set means method is not allowed)") } flag.Parse() @@ -245,20 +248,23 @@ func InitConfig() (*Config, error) { cfg.ProxySocketEndpointFileMode = os.FileMode(uint32(endpointFileMode)) // compile regexes for default allowed requests - cfg.AllowLists.Default.AllowedRequests = make(map[string]*regexp.Regexp) + cfg.AllowLists.Default.AllowedRequests = make(map[string][]*regexp.Regexp) for _, rx := range mr { - if rx.regexStringFromParam != "" { - r, err := compileRegexp(rx.regexStringFromParam, rx.method, "command line parameter") - if err != nil { - return nil, err - } - cfg.AllowLists.Default.AllowedRequests[rx.method] = r - } else if rx.regexStringFromEnv != "" { - r, err := compileRegexp(rx.regexStringFromEnv, rx.method, "env variable") - if err != nil { - return nil, err + for _, regexString := range rx.regexStrings { + if regexString.value != "" { + location := "" + switch regexString.from { + case fromEnv: + location = "env variable" + case fromParam: + location = "command line parameter" + } + r, err := compileRegexp(regexString.value, rx.method, location) + if err != nil { + return nil, err + } + cfg.AllowLists.Default.AllowedRequests[rx.method] = append(cfg.AllowLists.Default.AllowedRequests[rx.method], r) } - cfg.AllowLists.Default.AllowedRequests[rx.method] = r } } @@ -634,18 +640,23 @@ func getSocketProxyContainerSummary(socketPath, proxyContainerName string) (cont } // extract Docker container allowlist label data from the container summary -func extractLabelData(cntr container.Summary) (map[string]*regexp.Regexp, []string, error) { - allowedRequests := make(map[string]*regexp.Regexp) +func extractLabelData(cntr container.Summary) (map[string][]*regexp.Regexp, []string, error) { + allowedRequests := make(map[string][]*regexp.Regexp) var allowedBindMounts []string for labelName, labelValue := range cntr.Labels { if strings.HasPrefix(labelName, allowedDockerLabelPrefix) && labelValue != "" { allowSpec := strings.ToUpper(strings.TrimPrefix(labelName, allowedDockerLabelPrefix)) - if slices.ContainsFunc(mr, func(rx methodRegex) bool { return rx.method == allowSpec }) { - r, err := compileRegexp(labelValue, allowSpec, "docker container label") + if slices.ContainsFunc(mr, func(rx methodRegex) bool { + // allowSpec starts with the method name like socket-proxy.allow.get.1 + return strings.HasPrefix(allowSpec, rx.method) + }) { + // extract the method name from allowSpec + method, _, _ := strings.Cut(allowSpec, ".") + r, err := compileRegexp(labelValue, method, "docker container label") if err != nil { return nil, nil, err } - allowedRequests[allowSpec] = r + allowedRequests[method] = append(allowedRequests[method], r) } else if allowSpec == "BINDMOUNTFROM" { var err error allowedBindMounts, err = parseAllowedBindMounts(labelValue) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6119a0b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "reflect" + "regexp" + "testing" + + "github.com/wollomatic/socket-proxy/internal/docker/api/types/container" +) + +func Test_extractLabelData(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + cntr container.Summary + want map[string][]*regexp.Regexp + want2 []string + wantErr bool + }{ + { + name: "valid labels with multiple methods and regexes", + cntr: container.Summary{ + Labels: map[string]string{ + "socket-proxy.allow.get.0": "regex1", + "socket-proxy.allow.get.1": "regex2", + "socket-proxy.allow.post": "regex3", + }, + }, + want: map[string][]*regexp.Regexp{ + "GET": {regexp.MustCompile("^regex1$"), regexp.MustCompile("^regex2$")}, + "POST": {regexp.MustCompile("^regex3$")}, + }, + want2: nil, + wantErr: false, + }, + { + name: "invalid regex in label value", + cntr: container.Summary{ + Labels: map[string]string{ + "socket-proxy.allow.get": "invalid[regex", + }, + }, + want: nil, + want2: nil, + wantErr: true, + }, + { + name: "non-allow labels are ignored", + cntr: container.Summary{ + Labels: map[string]string{ + "socket-proxy.allow.get": "regex1", + "other.label": "value", + }, + }, + want: map[string][]*regexp.Regexp{ + "GET": {regexp.MustCompile("^regex1$")}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got2, gotErr := extractLabelData(tt.cntr) + if gotErr != nil { + if !tt.wantErr { + t.Errorf("extractLabelData() failed: %v", gotErr) + } + return + } + if tt.wantErr { + t.Fatal("extractLabelData() succeeded unexpectedly") + } + if !regexMapsEqual(got, tt.want) { + t.Errorf("extractLabelData() = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got2, tt.want2) { + t.Errorf("extractLabelData() = %v, want %v", got2, tt.want2) + } + }) + } +} + +func regexMapsEqual(a, b map[string][]*regexp.Regexp) bool { + if len(a) != len(b) { + return false + } + for method, aRegexes := range a { + bRegexes, ok := b[method] + if !ok || len(aRegexes) != len(bRegexes) { + return false + } + for i, ar := range aRegexes { + if ar.String() != bRegexes[i].String() { + return false + } + } + } + return true +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..12ea3dc --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,28 @@ +package config + +import ( + "strings" +) + +const sp_allowPrefix = "SP_ALLOW_" + +// getAllowFromEnv reads allowlist regex strings from environment variables. +// +// Environment variables should be of the form +// like SP_ALLOW_GET, SP_ALLOW_GET_0, SP_ALLOW_GET_1, SP_ALLOW_POST +// returning a map of method to list of regex strings. +// like: {"GET":[], "POST":[]} +func getAllowFromEnv(env []string) map[string][]string { + result := make(map[string][]string) + for _, v := range env { + if v, ok := strings.CutPrefix(v, sp_allowPrefix); ok { + key, value, found := strings.Cut(v, "=") + if found { + // optional number suffix after method + method, _, _ := strings.Cut(key, "_") + result[method] = append(result[method], value) + } + } + } + return result +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 0000000..aadb948 --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "reflect" + "testing" +) + +func Test_getAllowFromEnv(t *testing.T) { + tests := []struct { + name string // description of this test case + // Named input parameters for target function. + env []string + want map[string][]string + }{ + { + name: "single method", + env: []string{"SP_ALLOW_GET=/allowed/path"}, + want: map[string][]string{"GET": {"/allowed/path"}}, + }, + { + name: "multiple methods", + env: []string{"SP_ALLOW_GET=/get/path", "SP_ALLOW_POST=/post/path"}, + want: map[string][]string{"GET": {"/get/path"}, "POST": {"/post/path"}}, + }, + { + name: "multiple entries for one method", + env: []string{"SP_ALLOW_GET=/path/one", "SP_ALLOW_GET_1=/path/two"}, + want: map[string][]string{"GET": {"/path/one", "/path/two"}}, + }, + { + name: "multiple entries for one method with non-sequential index", + env: []string{"SP_ALLOW_GET=/path/one", "SP_ALLOW_GET_2=/path/two"}, + want: map[string][]string{"GET": {"/path/one", "/path/two"}}, + }, + { + name: "no relevant env vars", + env: []string{"OTHER_ENV=some_value"}, + want: map[string][]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getAllowFromEnv(tt.env) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getAllowFromEnv() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/config/param.go b/internal/config/param.go new file mode 100644 index 0000000..3408e04 --- /dev/null +++ b/internal/config/param.go @@ -0,0 +1,36 @@ +package config + +import ( + "flag" + "strings" +) + +type from int + +const ( + fromEnv from = 1 + fromParam from = 2 +) + +type param struct { + value string + from from +} + +type arrayParams []param + +// ensure that arrayParams implements the flag.Value interface +var _ flag.Value = (*arrayParams)(nil) + +func (a *arrayParams) String() string { + var values []string + for _, p := range *a { + values = append(values, p.value) + } + return strings.Join(values, ", ") +} + +func (a *arrayParams) Set(value string) error { + *a = append(*a, param{value: value, from: fromParam}) + return nil +}