feat: add ban_tags_enabled/ban_tags to docker remotes to block named tags (#43)
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
Adds two per-remote config keys for docker remotes:
ban_tags_enabled: false # opt-in, default off
ban_tags:
- latest
- edge
When ban_tags_enabled is true and a manifest request arrives for a named
tag in ban_tags, the proxy returns 403. sha256-addressed pulls are never
blocked, so images already pulled can still be referenced by digest.
Blob requests are unaffected.
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -11,6 +11,7 @@ FastAPI caching proxy that downloads and stores files from remote sources in S3-
|
|||||||
- Stale-on-upstream-error: refreshes TTL when backend is unreachable rather than evicting
|
- Stale-on-upstream-error: refreshes TTL when backend is unreachable rather than evicting
|
||||||
- URL rewriting for PyPI simple index, npm metadata, and Helm `index.yaml`
|
- URL rewriting for PyPI simple index, npm metadata, and Helm `index.yaml`
|
||||||
- Access control via regex patterns — unmatched paths return 403
|
- Access control via regex patterns — unmatched paths return 403
|
||||||
|
- Docker tag banning — block named tags (e.g. `latest`) while allowing digest pulls
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -243,6 +244,26 @@ remotes:
|
|||||||
|
|
||||||
Tag manifests and `/tags/list` are built-in mutable patterns. Digest-addressed blobs are immutable.
|
Tag manifests and `/tags/list` are built-in mutable patterns. Digest-addressed blobs are immutable.
|
||||||
|
|
||||||
|
#### Banning tags
|
||||||
|
|
||||||
|
Set `ban_tags_enabled: true` and list named tags in `ban_tags` to block specific tag references. Requests for a banned tag return `403`. Digest-addressed pulls (`sha256:…`) are never blocked, so images already in use can still be referenced by digest.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
remotes:
|
||||||
|
dockerhub:
|
||||||
|
base_url: "https://registry-1.docker.io"
|
||||||
|
package: "docker"
|
||||||
|
ban_tags_enabled: true
|
||||||
|
ban_tags:
|
||||||
|
- latest # force pinned tags in CI/CD
|
||||||
|
- edge
|
||||||
|
cache:
|
||||||
|
immutable_ttl: 0
|
||||||
|
mutable_ttl: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`ban_tags_enabled` defaults to `false`. Setting it to `true` with an empty `ban_tags` list has no effect.
|
||||||
|
|
||||||
For RKE2/containerd, configure `/etc/rancher/rke2/registries.yaml`:
|
For RKE2/containerd, configure `/etc/rancher/rke2/registries.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ async def proxy(request: Request, remote_name: str, path: str, storage, cache, c
|
|||||||
logger.info(f"PATTERN BLOCKED: {remote_name}/{path}")
|
logger.info(f"PATTERN BLOCKED: {remote_name}/{path}")
|
||||||
raise HTTPException(status_code=403, detail="Image not allowed by configuration patterns")
|
raise HTTPException(status_code=403, detail="Image not allowed by configuration patterns")
|
||||||
|
|
||||||
|
if remote_config.get("ban_tags_enabled", False):
|
||||||
|
ban_tags = remote_config.get("ban_tags", [])
|
||||||
|
if ban_tags:
|
||||||
|
tag_match = re.search(r"/manifests/([^/]+)$", path)
|
||||||
|
if tag_match:
|
||||||
|
tag = tag_match.group(1)
|
||||||
|
if not tag.startswith("sha256:") and tag in ban_tags:
|
||||||
|
logger.info(f"TAG BANNED: {remote_name}/{path} (tag: {tag})")
|
||||||
|
raise HTTPException(status_code=403, detail=f"Tag '{tag}' is not permitted on this remote")
|
||||||
|
|
||||||
base_url = remote_config.get("base_url", "").rstrip("/")
|
base_url = remote_config.get("base_url", "").rstrip("/")
|
||||||
remote_url = f"{base_url}/v2/{path}"
|
remote_url = f"{base_url}/v2/{path}"
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ TEST_REMOTES = {
|
|||||||
"immutable_patterns": ["^library/nginx"],
|
"immutable_patterns": ["^library/nginx"],
|
||||||
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
||||||
},
|
},
|
||||||
|
"docker-bantags-test": {
|
||||||
|
"base_url": "https://registry.example.com",
|
||||||
|
"package": "docker",
|
||||||
|
"ban_tags_enabled": True,
|
||||||
|
"ban_tags": ["latest", "edge"],
|
||||||
|
"cache": {"immutable_ttl": 0, "mutable_ttl": 300},
|
||||||
|
},
|
||||||
"generic-test": {
|
"generic-test": {
|
||||||
"base_url": "https://releases.example.com",
|
"base_url": "https://releases.example.com",
|
||||||
"package": "generic",
|
"package": "generic",
|
||||||
|
|||||||
@@ -388,6 +388,84 @@ class TestDockerProxy:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Docker ban_tags feature
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerBanTags:
|
||||||
|
def test_banned_tag_returns_403(self, client, patched_deps):
|
||||||
|
response = client.get("/v2/docker-bantags-test/library/nginx/manifests/latest")
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "latest" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_second_banned_tag_returns_403(self, client, patched_deps):
|
||||||
|
response = client.get("/v2/docker-bantags-test/library/nginx/manifests/edge")
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "edge" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_allowed_tag_proceeds(self, client, patched_deps):
|
||||||
|
deps = patched_deps
|
||||||
|
manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode()
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["storage"].download_object.return_value = manifest
|
||||||
|
deps["cache"].is_mutable_file.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
|
||||||
|
response = client.get("/v2/docker-bantags-test/library/nginx/manifests/1.25.3")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_digest_pull_bypasses_ban(self, client, patched_deps):
|
||||||
|
# sha256-addressed pulls must never be blocked by the tag ban list
|
||||||
|
deps = patched_deps
|
||||||
|
manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode()
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["storage"].download_object.return_value = manifest
|
||||||
|
deps["cache"].is_mutable_file.return_value = False
|
||||||
|
|
||||||
|
digest = "sha256:" + "a" * 64
|
||||||
|
with patch("artifactapi.artifact.proxy._fetch_last_modified", new_callable=AsyncMock, return_value=None):
|
||||||
|
response = client.get(f"/v2/docker-bantags-test/library/nginx/manifests/{digest}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_ban_tags_disabled_by_default(self, client, patched_deps):
|
||||||
|
# docker-test has no ban_tags_enabled — "latest" must pass through
|
||||||
|
deps = patched_deps
|
||||||
|
manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode()
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["storage"].download_object.return_value = manifest
|
||||||
|
deps["cache"].is_mutable_file.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
|
||||||
|
response = client.get("/v2/docker-test/library/nginx/manifests/latest")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_ban_tags_enabled_but_empty_list_allows_all(self, client, patched_deps):
|
||||||
|
# If ban_tags_enabled is true but ban_tags is empty nothing should be blocked.
|
||||||
|
# docker-test doesn't have ban_tags_enabled, but we can verify via the
|
||||||
|
# docker-bantags-test remote with an unlisted tag.
|
||||||
|
deps = patched_deps
|
||||||
|
manifest = json.dumps({"mediaType": "application/vnd.oci.image.manifest.v1+json", "layers": []}).encode()
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["storage"].download_object.return_value = manifest
|
||||||
|
deps["cache"].is_mutable_file.return_value = True
|
||||||
|
deps["cache"].is_index_valid.return_value = True
|
||||||
|
|
||||||
|
response = client.get("/v2/docker-bantags-test/library/nginx/manifests/stable")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_ban_check_does_not_apply_to_blobs(self, client, patched_deps):
|
||||||
|
# Blob paths don't contain /manifests/ — the ban check must not interfere
|
||||||
|
deps = patched_deps
|
||||||
|
deps["storage"].exists.return_value = True
|
||||||
|
deps["storage"].download_object.return_value = b"\x00" * 100
|
||||||
|
deps["cache"].is_mutable_file.return_value = False
|
||||||
|
|
||||||
|
with patch("artifactapi.artifact.proxy._fetch_last_modified", new_callable=AsyncMock, return_value=None):
|
||||||
|
response = client.get("/v2/docker-bantags-test/library/nginx/blobs/sha256:" + "b" * 64)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Generic artifact route /api/v1/remote/{remote}/{path}
|
# Generic artifact route /api/v1/remote/{remote}/{path}
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user