diff --git a/README.md b/README.md index abd4cd1..c24ac44 100644 --- a/README.md +++ b/README.md @@ -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 - URL rewriting for PyPI simple index, npm metadata, and Helm `index.yaml` - Access control via regex patterns — unmatched paths return 403 +- Docker tag banning — block named tags (e.g. `latest`) while allowing digest pulls ## Architecture @@ -243,6 +244,26 @@ remotes: 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`: ```yaml diff --git a/src/artifactapi/artifact/docker.py b/src/artifactapi/artifact/docker.py index ba40612..3c404df 100644 --- a/src/artifactapi/artifact/docker.py +++ b/src/artifactapi/artifact/docker.py @@ -33,6 +33,16 @@ async def proxy(request: Request, remote_name: str, path: str, storage, cache, c logger.info(f"PATTERN BLOCKED: {remote_name}/{path}") 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("/") remote_url = f"{base_url}/v2/{path}" diff --git a/tests/conftest.py b/tests/conftest.py index 4c931c2..b7f87ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,6 +41,13 @@ TEST_REMOTES = { "immutable_patterns": ["^library/nginx"], "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": { "base_url": "https://releases.example.com", "package": "generic", diff --git a/tests/test_routes.py b/tests/test_routes.py index 1bea394..2f3808e 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -261,6 +261,84 @@ class TestDockerProxy: 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} # ---------------------------------------------------------------------------