Repository navigation
[Bug] Docker Labels Race Condition #83
Description
Describe the bug
When Docker labels are used to control socket-proxy access, if an application boots up and attempts to access the proxy right away, it will be blocked before the proxy reads the labels and applies the correct policy.
To Reproduce
Steps to reproduce the behavior:
- Deploy a Beszel agent similar to my configuration
- Observe the Beszel agent's attempt to access the socket and get blocked (based on forbidden IP) despite the request abiding by the regex
- Observe the socket proxy update with the correct access policy too late
Expected behavior
Requests made at container creation/restart time should not get blocked and the access policy should be queried/fetched first.
Desktop (please complete the following information):
- OS: Debian Linux
- Version: 1.11.0
Additional context
In the logs below, you can see me doing a mix of a) applying new regex policies to the Beszel agent container and b) restarting it, which both result in the following sequence:
- The allowlist is correctly removed for the container as it disappears
- The request by the Beszel agent is blocked based on IP (hasn't even gotten to the allowed paths)
- The allowlist is correctly propagated for the container
Not sure if it's relevant or not, but I run two Beszel agents on the host, and they share a Docker network, whereas the rest of my services using the socket proxy have their own network. Both are having the same issue and is blocking me from getting the Docker statistics I was getting before.
2026-01-19T21:30:17.274260651Z time=2026-01-19T21:30:17.273Z level=INFO msg="starting socket-proxy" version=1.11.0 os=linux arch=arm64 runtime=go1.25.6 URL=github.com/wollomatic/socket-proxy
2026-01-19T21:30:17.274437909Z time=2026-01-19T21:30:17.274Z level=INFO msg="configuration info" socketpath=/var/run/docker.sock listenaddress=0.0.0.0:2375 loglevel=INFO logjson=false allowfrom=127.0.0.1/32 shutdowngracetime=10
2026-01-19T21:30:17.274472702Z time=2026-01-19T21:30:17.274Z level=INFO msg="watchdog disabled"
2026-01-19T21:30:17.274484703Z time=2026-01-19T21:30:17.274Z level=INFO msg="Proxy container name provided" proxycontainername=socket-proxy
2026-01-19T21:30:17.274495411Z time=2026-01-19T21:30:17.274Z level=INFO msg="per-container allowlists enabled!"
2026-01-19T21:30:17.274505620Z time=2026-01-19T21:30:17.274Z level=INFO msg="socket proxy networks detected" socketproxynetworks="[socket-proxy-tinyauth socket-proxy-beszel-agent socket-proxy-caddy socket-proxy-diun socket-proxy-homepage]"
2026-01-19T21:30:17.274517204Z Default request allowlist:
2026-01-19T21:30:17.274527580Z Method Regex
2026-01-19T21:30:17.274537580Z GET ^(/v1\.\d{1,2})?/(_ping|version)$
2026-01-19T21:30:17.274547164Z HEAD ^(/v1\..{1,2})?/_ping$
2026-01-19T21:30:17.274820510Z time=2026-01-19T21:30:17.274Z level=INFO msg="socket-proxy running and listening..."
2026-01-19T21:30:17.279974205Z Request allowlist for c67060cd9dd4 (172.20.0.3):
2026-01-19T21:30:17.280028833Z Method Regex
2026-01-19T21:30:17.280034625Z HEAD ^(/v1\.\d{1,2})?/_ping$
2026-01-19T21:30:17.280039542Z GET ^(/v1\.\d{1,2})?/(info|containers/json(\?\S+)?|events(\?\S+)?|networks(/\w+)?)$
2026-01-19T21:30:17.280044375Z Request allowlist for ff712e71df1c (172.18.0.2):
2026-01-19T21:30:17.280048667Z Method Regex
2026-01-19T21:30:17.280052709Z GET ^(/v1\.\d{1,2})?/(version|containers/json(\?\S+)?|images/[\w./:+-]+/json)$
2026-01-19T21:30:17.280057042Z HEAD ^(/v1\.\d{1,2})?/_ping$
2026-01-19T21:30:17.280060876Z Request allowlist for a7fef33ed3e2 (172.30.0.2):
2026-01-19T21:30:17.280064709Z Method Regex
2026-01-19T21:30:17.280068209Z HEAD ^(/v1\.\d{1,2})?/_ping$
2026-01-19T21:30:17.280071960Z GET ^(/v1\.\d{1,2})?/containers/(json|[\w.-]+/json)(\?\S+)?$
2026-01-19T21:30:17.280076043Z Request allowlist for ab99a9d0a726 (172.19.0.3):
2026-01-19T21:30:17.280079793Z Method Regex
2026-01-19T21:30:17.280083419Z GET ^(/v1\.\d{1,2})?/containers/(json|[\w.-]+/(json|stats))(\?\S+)?$
2026-01-22T06:29:25.148084580Z time=2026-01-22T06:29:25.143Z level=INFO msg="removed allowlist for container" id=ab99a9d0a726 ip=172.19.0.3
2026-01-22T06:29:26.311599748Z time=2026-01-22T06:29:26.310Z level=WARN msg="blocked request" reason="forbidden IP" method=GET URL=/version client=172.19.0.3:41356 response=403
2026-01-22T06:29:26.316413183Z Request allowlist for 9ed3b64d531e (172.19.0.3):
2026-01-22T06:29:26.316513312Z Method Regex
2026-01-22T06:29:26.316530563Z GET ^(/v1\.\d{1,2})?/(info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
2026-01-22T06:31:53.171126074Z time=2026-01-22T06:31:53.170Z level=INFO msg="removed allowlist for container" id=9ed3b64d531e ip=172.19.0.3
2026-01-22T06:31:53.558768071Z time=2026-01-22T06:31:53.558Z level=WARN msg="blocked request" reason="forbidden IP" method=GET URL=/version client=172.19.0.3:51192 response=403
2026-01-22T06:31:53.566160567Z Request allowlist for 9ed3b64d531e (172.19.0.3):
2026-01-22T06:31:53.566238987Z Method Regex
2026-01-22T06:31:53.566247779Z GET ^(/v1\.\d{1,2})?/(info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
2026-01-22T06:31:53.568447657Z Request allowlist for 9ed3b64d531e (172.19.0.3):
2026-01-22T06:31:53.568467449Z Method Regex
2026-01-22T06:31:53.568475199Z GET ^(/v1\.\d{1,2})?/(info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
2026-01-22T06:32:52.051984892Z time=2026-01-22T06:32:52.051Z level=INFO msg="removed allowlist for container" id=9ed3b64d531e ip=172.19.0.3
2026-01-22T06:32:53.204537656Z time=2026-01-22T06:32:53.204Z level=WARN msg="blocked request" reason="forbidden IP" method=GET URL=/version client=172.19.0.3:59116 response=403
2026-01-22T06:32:53.218934970Z Request allowlist for c5f83da70ca7 (172.19.0.3):
2026-01-22T06:32:53.219005472Z Method Regex
2026-01-22T06:32:53.219013806Z GET ^(/v1\.\d{1,2})?/(version|info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
2026-01-22T06:33:38.873435441Z time=2026-01-22T06:33:38.873Z level=INFO msg="removed allowlist for container" id=c5f83da70ca7 ip=172.19.0.3
2026-01-22T06:33:39.263954237Z time=2026-01-22T06:33:39.263Z level=WARN msg="blocked request" reason="forbidden IP" method=GET URL=/version client=172.19.0.3:34116 response=403
2026-01-22T06:33:39.272622577Z Request allowlist for c5f83da70ca7 (172.19.0.3):
2026-01-22T06:33:39.272711622Z Method Regex
2026-01-22T06:33:39.272721206Z GET ^(/v1\.\d{1,2})?/(version|info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
2026-01-22T06:33:39.274679408Z Request allowlist for c5f83da70ca7 (172.19.0.3):
2026-01-22T06:33:39.274719576Z Method Regex
2026-01-22T06:33:39.274725243Z GET ^(/v1\.\d{1,2})?/(version|info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
2026-01-22T06:34:45.990249369Z time=2026-01-22T06:34:45.990Z level=INFO msg="removed allowlist for container" id=c5f83da70ca7 ip=172.19.0.3
2026-01-22T06:34:50.752432138Z time=2026-01-22T06:34:50.752Z level=WARN msg="blocked request" reason="forbidden IP" method=GET URL=/version client=172.19.0.3:49316 response=403
2026-01-22T06:34:50.757763682Z Request allowlist for 2ce4d13e5e89 (172.19.0.3):
2026-01-22T06:34:50.757853602Z Method Regex
2026-01-22T06:34:50.757869895Z GET ^(/v1\.\d{1,2})?/(version|info|containers/(json|[\w.-]+/(json|stats))(\?\S+)?)$
In terms of ideas for a fix, maybe before blocking a request based on IP, the socket-proxy should do a manual query of that container to see if it has the expected labels instead of relying on the Docker socket event stream to have updated the allowlist in time.
cc: @amanda-wee
Activity
amanda-wee commentedon Jan 22, 2026
I encountered a similiar issue with Beszel's
/versioncheck last year and contributed retry logic, but it looks like that is still insufficient.True, and because we don't see the warnings "cannot get valid IP address from request" or "cannot get valid IP address for client allowlist check", this means that it was blocked because it did not qualify based on the
-allowfromparameter orSP_ALLOWFROMenvironment variable.Unfortunately we only know the IP address at that point, and there isn't a Docker Engine API call to directly filter for the container corresponding to an IP address. Nonetheless, this could be possible with one Docker Engine API call to list all the containers in all the networks that the socket-proxy is in, and then iterating over the list until a match is found for the IP address, and then the labels can be found (or not). Is it a good idea to have such a potentially expensive operation when handling the requests? It wouldn't be in the happy path, and we could eliminate this performance penalty for those not using per-container allowlists by only doing this fallback if the proxy container name setting is non-empty.
That said, given that you're allowing
/versionfor the default allowlist, there is a straightforward fix in your case: addbeszel-agenttoSP_ALLOWFROM. But that wouldn't work if you didn't want a default allowlist, or if some service was trying to use a far more sensitive endpoint that failed due to this race condition, hence it would be a bad idea to include it in a default allowlist.ElioDiNino commentedon Jan 23, 2026
It looks like the problem here is that the retry doesn't kick in if any response is received (regardless of status code), and in this case, the socket-proxy returns a valid response with status code 403. So, the Beszel agent breaks out of the retry loop on line 594 and then proceeds to attempt to decode the Docker version despite the 403 status and fails on line 608. This is supported by the error I see from Beszel running with debug logs:
Ah, great point! I think querying all the containers that the socket proxy shares a network with is the next best solution. Some additional optimization could be done by first eliminating network possibilities based on their subnet and/or IP range found in the IPAM object of the Docker Engine network inspect endpoint.
This is something I was also debating. I think/hope that with my optimization suggestion above, it would not be too much of a penalty, but I agree that it only makes sense to enable this when the proxy container name setting is non-empty.
I will do this for now to get my Docker statistics back, but yeah, I think this is not a generally advisable solution for someone if they are trying to fully use Docker labels for configuration and I hope a solution can be implemented :)
fix(socket-proxy): Temporary workaround for race condition
chore(tinyauth): Allow socket proxy ping as GET request
ElioDiNino commentedon Feb 21, 2026
As seen in the linked PR references (henrygd/beszel#1754), there is now an upstream fix/improvement in Beszel to retry for this race condition, but I will leave this issue open since the core issue in the socket proxy remains.
johnnyggalt commentedon Jul 25, 2026
I hit the same issue with Dozzle (
ghcr.io/amir20/dozzle:latest), fwiw. Depending on the outcome of the race, Dozzle will either work perfectly or exit with:wollomatic commentedon Jul 25, 2026
This is a bigger refactor than I initially thought. Here's the current test image:
ghcr.io/wollomatic/socket-proxy:testing-4c6077ea7910a90ab2892054cd09e8189ca692ad
johnnyggalt commentedon Jul 25, 2026
Thanks @wollomatic. I've been playing with that test image. My chances of success seem to be massively improved (though still not perfect) by doing a
docker compose downbefore Idocker compose upmy Dozzle stack (as opposed to simplydocker compose upand ctrl+c to stop the stack, then bring it up again). So it seems that having to completely recreate the network and container of my Dozzle stack reduces the chances of triggering the race condition.That said, I honestly didn't try that before switching to the test image.
wollomatic commentedon Jul 26, 2026
HI @johnnyggalt, thanks for the feedback.
I decreased the cache time, so maybe the chances of success increase further. Here´s the new test image:
ghcr.io/wollomatic/socket-proxy:testing-719d070a22c2a47bd9d84a1a4c98e33a2b1c441f
johnnyggalt commentedon Jul 30, 2026
Just tried this one. Also get failures every now and then with
docker compose downthendocker compose up. Maybe 1 in 10 fails. Another observation is that when it does fail, it seems to spend longer at theAttaching to dozzlestage of output before Dozzle spits out any logs.wollomatic commentedon Aug 6, 2026
Thanks everyone for investigating and testing this.
The changes implemented in #163 significantly reduce the likelihood of this race condition. When a request arrives from an IP address that is not yet known, the proxy now performs a controlled refresh of the label-derived allowlists before denying the request. Concurrent refresh requests are coalesced, and recently refreshed data can be reused within a cooldown period.
However, this can only minimize the race condition, not eliminate it completely. The underlying problem is the Docker container lifecycle: labels and network information can only be discovered through the Docker API once the container exists and has been attached to its networks. At that point, the application inside the container may already have started and attempted to contact the proxy.
It would be possible to retry Docker lookups repeatedly whenever a request cannot be associated with an existing allowlist. I do not consider that desirable, particularly from a security perspective. A client that is not currently authorized should not be able to cause an effectively unbounded number of Docker socket queries simply by sending repeated requests. Apart from the additional load and complexity, this would expose a privileged backend operation to externally influenced request frequency and could provide an unnecessary denial-of-service vector.
The implementation therefore deliberately performs only a controlled refresh and continues to fail closed if the requesting container still cannot be identified. This improves the common startup case without weakening the proxy’s security model or turning denied requests into unrestricted Docker API polling.
Applications that tolerate an initial
403and retry may handle the remaining edge case gracefully, but this depends on the individual client and cannot be treated as a reliable access-control guarantee.For fully deterministic access control, my recommendation remains to run a separate socket-proxy instance for each service, using an appropriate static configuration. Each proxy can share a private network only with its intended client. The resource overhead of an additional socket-proxy container is small, while the resulting configuration is predictable from startup and provides stronger isolation between services.
Per-container label configuration remains available as a convenient dynamic option, and #163 makes it considerably more robust. Users should nevertheless be aware that Docker’s lifecycle prevents it from guaranteeing that the very first request during container startup will always be accepted.
amanda-wee commentedon Aug 6, 2026
Thanks for working on this. I feel like if we had gone with a static config file as was originally suggested instead of the Traefik-style dynamic container labels then this wouldn't have been an issue to begin with since it'll be either the socket proxy isn't up and hence cannot accept the connection, or is it up and already has the allowlist configuration, but I admit I was intrigued by the idea of implementing Traefik-style labels and put off by the lack of a YAML parser in the Go standard library.
ElioDiNino commentedon Aug 9, 2026
Thanks for the hard work on this @wollomatic! The solution you landed on makes total sense and greatly improves the race conditions I previously ran into.