Files
watarika 277e45b369 fix: support secure Civitai red downloads
Use Bearer authentication for Civitai and strip it before cross-origin artifact redirects. Recommend civitai.red and release the fix as 0.2.2.
2026-07-11 22:50:30 +09:00

317 lines
9.6 KiB
Python

from pathlib import Path
from urllib.request import Request
import pytest
import model_batch_downloader.resolution as resolution
import model_batch_downloader.security as security
from model_batch_downloader.manifest import ManifestError, ManifestItem
from model_batch_downloader.resolution import resolve_manifest
from model_batch_downloader.security import (
auth_for_url,
provider_for_url,
redact,
)
def test_resolve_uses_content_disposition_and_derives_id(tmp_path):
item = ManifestItem(
"https://civitai.com/api/download/models/42", "checkpoints"
)
resolved = resolve_manifest(
(item,),
{"checkpoints": (tmp_path / "checkpoints",)},
probe=lambda _url, _headers: "Realism.Illustrious.safetensors",
environ={},
)
assert resolved[0].filename == "Realism.Illustrious.safetensors"
assert resolved[0].item_id == "Realism.Illustrious"
assert resolved[0].relative_path == Path("Realism.Illustrious.safetensors")
def test_resolve_rejects_case_insensitive_duplicate_ids(tmp_path):
items = (
ManifestItem(
"https://example.com/a.safetensors",
"loras",
filename="a.safetensors",
item_id="Style",
),
ManifestItem(
"https://example.com/b.safetensors",
"loras",
filename="b.safetensors",
item_id="style",
),
)
with pytest.raises(ManifestError, match="duplicate id"):
resolve_manifest(items, {"loras": (tmp_path,)}, lambda *_: None, {})
def test_resolve_rejects_duplicate_destination(tmp_path):
items = (
ManifestItem(
"https://example.com/a.safetensors",
"loras",
filename="same.safetensors",
item_id="first",
),
ManifestItem(
"https://example.com/b.safetensors",
"loras",
filename="same.safetensors",
item_id="second",
),
)
with pytest.raises(ManifestError, match="duplicate destination"):
resolve_manifest(items, {"loras": (tmp_path,)}, lambda *_: None, {})
def test_resolve_skips_existing_file_in_secondary_root(tmp_path):
primary, secondary = tmp_path / "primary", tmp_path / "secondary"
existing = secondary / "krea2" / "qwen_image_vae.safetensors"
existing.parent.mkdir(parents=True)
existing.write_bytes(b"existing")
item = ManifestItem(
"https://example.com/qwen_image_vae.safetensors",
"vae",
subfolder="krea2",
filename="qwen_image_vae.safetensors",
)
resolved = resolve_manifest(
(item,), {"vae": (primary, secondary)}, lambda *_: None, {}
)[0]
assert resolved.existing_path == existing.resolve()
assert resolved.destination == (
primary / "krea2" / "qwen_image_vae.safetensors"
).resolve()
def test_resolve_rejects_secondary_root_symlink_escape(tmp_path):
primary = tmp_path / "primary"
secondary = tmp_path / "secondary"
outside = tmp_path / "outside"
primary.mkdir()
secondary.mkdir()
outside.mkdir()
try:
(secondary / "linked").symlink_to(outside, target_is_directory=True)
except OSError:
pytest.skip("symlinks are unavailable for this Windows account")
(outside / "model.safetensors").write_bytes(b"outside")
item = ManifestItem(
"https://example.com/model.safetensors",
"checkpoints",
subfolder="linked",
filename="model.safetensors",
)
with pytest.raises(ManifestError, match="escapes"):
resolve_manifest(
(item,), {"checkpoints": (primary, secondary)}, lambda *_: None, {}
)
@pytest.mark.parametrize("domain", ("civitai.com", "civitai.red"))
def test_auth_uses_environment_only(domain):
huggingface = auth_for_url(
"https://huggingface.co/a/b", {"HF_TOKEN": "hf_secret"}
)
assert huggingface.header == ("Authorization", "Bearer hf_secret")
civitai = auth_for_url(
f"https://{domain}/api/download/models/1",
{"CIVITAI_API_TOKEN": "cv_secret"},
)
assert provider_for_url(f"https://{domain}/api/download/models/1") == "civitai"
assert civitai.header == ("Authorization", "Bearer cv_secret")
def test_public_host_has_no_authentication():
auth = auth_for_url("https://example.com/model.safetensors", {})
assert auth.provider == "public"
assert auth.header is None
def test_civitai_cross_origin_redirect_drops_bearer_header():
assert hasattr(security, "resolve_download_source")
requests = []
class Response:
status = 307
headers = {"Location": "https://b2.civitai.com/file/model?signature=signed"}
def close(self):
pass
def open_request(request):
requests.append(request)
return Response()
auth = auth_for_url(
"https://civitai.red/api/download/models/42",
{"CIVITAI_API_TOKEN": "cv_secret"},
)
source = security.resolve_download_source(
"https://civitai.red/api/download/models/42",
auth,
open_request=open_request,
)
assert source.url == "https://b2.civitai.com/file/model?signature=signed"
assert source.headers == {"User-Agent": security.CIVITAI_USER_AGENT}
assert requests[0].get_header("Authorization") == "Bearer cv_secret"
assert requests[0].get_header("User-agent") == security.CIVITAI_USER_AGENT
def test_civitai_direct_response_keeps_bearer_for_aria2():
assert hasattr(security, "resolve_download_source")
class Response:
status = 200
headers = {}
def close(self):
pass
auth = auth_for_url(
"https://civitai.red/api/download/models/42",
{"CIVITAI_API_TOKEN": "cv_secret"},
)
source = security.resolve_download_source(
"https://civitai.red/api/download/models/42",
auth,
open_request=lambda _request: Response(),
)
assert source.url == "https://civitai.red/api/download/models/42"
assert source.headers == {
"Authorization": "Bearer cv_secret",
"User-Agent": security.CIVITAI_USER_AGENT,
}
def test_civitai_redirect_rejects_https_downgrade():
class Response:
status = 307
headers = {"Location": "http://civitai.red/file/model"}
def close(self):
pass
auth = auth_for_url(
"https://civitai.red/api/download/models/42",
{"CIVITAI_API_TOKEN": "cv_secret"},
)
with pytest.raises(RuntimeError, match="HTTPS"):
security.resolve_download_source(
"https://civitai.red/api/download/models/42",
auth,
open_request=lambda _request: Response(),
)
def test_civitai_redirect_to_nonstandard_port_drops_bearer():
class Response:
status = 307
headers = {"Location": "https://civitai.red:8443/file/model"}
def close(self):
pass
auth = auth_for_url(
"https://civitai.red/api/download/models/42",
{"CIVITAI_API_TOKEN": "cv_secret"},
)
source = security.resolve_download_source(
"https://civitai.red/api/download/models/42",
auth,
open_request=lambda _request: Response(),
)
assert source.url == "https://civitai.red:8443/file/model"
assert source.headers == {"User-Agent": security.CIVITAI_USER_AGENT}
def test_probe_redirect_drops_bearer_outside_original_origin():
assert hasattr(resolution, "_SafeRedirectHandler")
handler = resolution._SafeRedirectHandler()
original = Request(
"https://civitai.red/api/download/models/42",
headers={
"Authorization": "Bearer cv_secret",
"User-Agent": security.CIVITAI_USER_AGENT,
},
)
redirected = handler.redirect_request(
original,
None,
307,
"Temporary Redirect",
{},
"https://b2.civitai.com/file/model?signature=signed",
)
assert redirected.get_header("Authorization") is None
assert redirected.get_header("User-agent") == security.CIVITAI_USER_AGENT
def test_redact_removes_exact_and_pattern_secrets():
text = (
"Authorization: Bearer hf_secret "
"https://civitai.com/x?token=cv_secret"
)
clean = redact(text, ("hf_secret", "cv_secret"))
assert "hf_secret" not in clean
assert "cv_secret" not in clean
assert "[REDACTED]" in clean
@pytest.mark.parametrize(
("text", "secret", "expected"),
[
(
"download https://cdn.example.com/model?X-Amz-Signature=supersecret&part=1#signed",
"supersecret",
"https://cdn.example.com/model?[REDACTED]#[REDACTED]",
),
(
"download https://alice:password@example.com/models/model.safetensors",
"password",
"https://[REDACTED]@example.com/models/model.safetensors",
),
],
)
def test_redact_sanitizes_authenticated_urls(text, secret, expected):
clean = redact(text)
assert secret not in clean
assert expected in clean
def test_redact_safely_replaces_malformed_url():
clean = redact(
"download https://[alice:password@example.com/model?signature=supersecret"
)
assert clean == "download https://[REDACTED]"
def test_resolution_error_redacts_civitai_token(tmp_path):
item = ManifestItem(
"https://civitai.com/api/download/models/42", "checkpoints"
)
def failing_probe(url, headers):
raise RuntimeError(f"failed request: {url} headers={headers}")
with pytest.raises(ManifestError) as captured:
resolve_manifest(
(item,),
{"checkpoints": (tmp_path,)},
failing_probe,
{"CIVITAI_API_TOKEN": "cv_secret"},
)
assert "cv_secret" not in str(captured.value)
assert "[REDACTED]" in str(captured.value)