From fd3c76d21b239deeaab5f4f1c17bd475756e2302 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Tue, 5 May 2026 08:18:02 +0200 Subject: [PATCH] fix(store): security + correctness blockers found in PR review (F1, F2, F4, F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent reviews of PR #180 surfaced four real defects in the new Store / my-ai-stack surface. CHANGELOG entries detail each; one-liners: - F1 video_url XSS: any authenticated user could upload a Store entity with `video_url=javascript:...` and pop XSS in any viewer's session via the `` "Watch video" link in store_detail.html. Jinja2 autoescape doesn't block URI schemes inside attribute values. Fixed by scheme-validating to http(s) only on create + update; 400 invalid_video_url. - F2 ZIP decompression bomb: _safe_zip_extract checked path-traversal but not declared file_size totals — a 50 MB compressed upload at 1:1000 ratio decompresses to 50 GB and DOS the host disk. Fixed by summing zinfo.file_size across infolist() and refusing > 200 MB before extractall touches disk. 413 zip_too_large_uncompressed. - F4 admin authz parity: PUT /api/store/entities/{id} was owner-only while DELETE allowed owner OR admin; the store-detail page hid Edit/Delete buttons from admin even though DELETE was permitted. Fixed by allowing admin on PUT and passing is_admin to the template; gate is now is_owner OR is_admin everywhere. - F5 cross-owner suffix collision: sanitize_username is many-to-one (alice.smith / alice_smith both → alice-smith). Two such users uploading entities with the same display name produced identical `-by-` suffixes, silently colliding in the served agnes-store-bundle on-disk paths AND the manifest catalog (Claude Code dedupes by plugin.json `name`). Fixed by enforcing global uniqueness on the suffixed value at create_entity; 409 conflict_global_suffix. F3 (ZIP symlink members) was investigated and confirmed to be a false-positive — Python's stdlib ZipFile.extractall does not honor symlink mode bits, so no exploit exists. 9 new regression tests in tests/test_store_api.py::TestStoreSecurityFixes covering all four. Test run locally: 60/60 store-related tests pass. --- CHANGELOG.md | 39 +++++ app/api/store.py | 104 ++++++++++++- app/web/router.py | 6 + app/web/templates/store_detail.html | 2 +- src/store_naming.py | 10 ++ tests/test_store_api.py | 225 ++++++++++++++++++++++++++++ 6 files changed, 381 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6d262..956c1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +## [0.35.0] — 2026-05-05 + ### Added - **`/store` page** — community marketplace where every authenticated user can upload skills, agents, and plugins as ZIPs. Listing has type / category / @@ -43,6 +45,11 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C `GET /api/store/categories`, `GET /api/store/owners`, `GET /api/my-stack`, `PUT /api/my-stack/curated/{marketplace_id}/{plugin_name}`. +- **CLI: `agnes store {list,show,install,uninstall,upload,delete}`** and + **`agnes my-stack {show,toggle}`** — full analyst-side coverage of the + new Store + composition REST surface. Multipart upload helper added to + `cli/v2_client.py` (`api_post_multipart` / `api_put_multipart`) so + future multipart endpoints don't have to roll their own httpx wiring. ### Changed - `/admin/marketplaces` admin nav entry moved from the top-level header into @@ -57,6 +64,32 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C `/marketplace/info` payload now splits its `plugins` array by `source`, exposing `plugins` (admin) and `store_plugins` (community). +### Fixed +- **Stored XSS via `video_url`** (`app/api/store.py`) — `video_url` accepted + on `POST/PUT /api/store/entities` is now scheme-validated to `http(s)://` + only. Previously a `javascript:` URI flowed through the form field into + `store_detail.html`'s `` and would execute in any viewer's + session on click. 400 `invalid_video_url` on bad input. +- **ZIP decompression bomb** (`app/api/store.py:_safe_zip_extract`) — the + uncompressed-side total of an upload is now capped at 200 MB + (`MAX_ZIP_UNCOMPRESSED`); the compressed-side cap (50 MB) alone did not + bound the on-disk footprint. 413 `zip_too_large_uncompressed` on + oversize. +- **Admin authz parity for Store mutations** (`app/api/store.py`, + `app/web/router.py`, `app/web/templates/store_detail.html`) — + `PUT /api/store/entities/{id}` now permits owner OR admin (matches + `DELETE`); the store-detail page passes `is_admin` to the template and + gates the Edit/Delete buttons on `is_owner OR is_admin`. Pre-fix, an + admin could delete via the API but saw no Edit/Delete affordance in the + UI, and could not update non-owned entities at all. +- **Cross-owner suffix collision** (`app/api/store.py:create_entity`) — + `sanitize_username` is many-to-one (`alice.smith` and `alice_smith` + both → `alice-smith`). Two such users uploading entities with the same + display `name` produced identical `-by-` suffixes, + silently colliding in the served bundle's on-disk paths and the + manifest catalog (Claude Code dedupes by `plugin.json`'s `name`). + We now refuse the second upload with 409 `conflict_global_suffix`. + ### Internal - Schema **v24 → v25**: adds `store_entities`, `user_store_installs`, `user_plugin_optouts`. Auto-migration via `_V24_TO_V25_MIGRATIONS` ladder @@ -70,6 +103,12 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C returns, parameterized SQL, no ORM). - `app/utils.py:get_store_dir()` — `${DATA_DIR}/store/`. - `humanbytes` Jinja2 filter on Store detail page (binary KB/MB/GB). +- New CLI command modules: `cli/commands/store.py`, `cli/commands/my_stack.py`. + Registered as Typer subapps `agnes store` and `agnes my-stack` in + `cli/main.py`. Tests at `tests/test_cli_store.py`. +- `tests/test_store_api.py:TestStoreSecurityFixes` — regression suite for + F1 (video_url), F2 (zip-bomb), F4 (admin authz parity), F5 (cross-owner + suffix collision). ## [0.34.0] — 2026-05-04 diff --git a/app/api/store.py b/app/api/store.py index e50e364..78cded4 100644 --- a/app/api/store.py +++ b/app/api/store.py @@ -27,6 +27,7 @@ import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any, List, Optional +from urllib.parse import urlparse import duckdb from fastapi import ( @@ -66,6 +67,63 @@ _CHUNK_SIZE = 64 * 1024 _VALID_TYPES = {"skill", "agent", "plugin"} _NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$") _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) +_ALLOWED_VIDEO_SCHEMES = {"http", "https"} + +# Cap on uncompressed total size of an uploaded ZIP. The compressed-side cap +# is MAX_ZIP_SIZE; an attacker could craft a 50 MB ZIP that decompresses to +# >>10 GB and DOS the host disk via _safe_zip_extract. We sum infolist() +# file_size before extracting and refuse anything above this bound. +MAX_ZIP_UNCOMPRESSED = 200 * 1024 * 1024 # 200 MB + + +def _suffixed_already_taken( + conn: duckdb.DuckDBPyConnection, + suffixed: str, + *, + exclude_entity_id: Optional[str] = None, +) -> bool: + """Whether any existing entity ships the same display+invocation name. + + The Store namespace is **flat** in Claude Code — two plugins/skills/agents + that share a ``name`` collide in the served marketplace catalog (the + ``manifest_name`` is unique-key for ``/plugin`` lookup) and on-disk inside + the ``agnes-store-bundle`` (skills//SKILL.md is the dir name). + + ``sanitize_username`` is many-to-one (``alice.smith`` and ``alice_smith`` + both → ``alice-smith``), so the per-owner UNIQUE on + ``(owner_user_id, name)`` does NOT prevent the cross-owner collision. + We enforce global uniqueness on ``name || '-by-' || owner_username`` + here, at upload time, with a clear 409. + """ + sql = ( + "SELECT id FROM store_entities " + "WHERE name || '-by-' || owner_username = ?" + ) + params: List[Any] = [suffixed] + if exclude_entity_id: + sql += " AND id != ?" + params.append(exclude_entity_id) + return bool(conn.execute(sql, params).fetchone()) + + +def _validate_video_url(value: Optional[str]) -> Optional[str]: + """Return the URL if it is a safe http(s) URL, raise 400 otherwise. + + Empty / None passes through as None — video_url is optional. Defends + against ``javascript:``, ``data:``, ``vbscript:`` (etc.) URIs that + would execute in the viewer's session if rendered inside an ``href``. + Jinja2 autoescape only HTML-escapes characters; it does not block URI + schemes inside attribute values. + """ + if value is None: + return None + s = value.strip() + if not s: + return None + parsed = urlparse(s) + if parsed.scheme.lower() not in _ALLOWED_VIDEO_SCHEMES or not parsed.netloc: + raise HTTPException(status_code=400, detail="invalid_video_url") + return s # --------------------------------------------------------------------------- @@ -251,11 +309,24 @@ async def _stream_to_temp( def _safe_zip_extract(zf: zipfile.ZipFile, dest: Path) -> None: - """Extract ``zf`` into ``dest`` while rejecting any member whose normalized - path would escape ``dest`` (zip-slip guard).""" + """Extract ``zf`` into ``dest`` while rejecting unsafe members. + + Three guards: + + 1. Path traversal (zip-slip) — refuse absolute paths or ``..`` segments. + 2. Decompression bomb — reject if the sum of declared uncompressed sizes + exceeds ``MAX_ZIP_UNCOMPRESSED``. The compressed-side cap + (``MAX_ZIP_SIZE``) does not bound the decompressed footprint; a 50 MB + ZIP at ratio 1:1000 expands to 50 GB on disk. + 3. (Note) Python's stdlib ``ZipFile.extractall`` does NOT honor symlink + mode bits — symlink entries are written as regular files containing + the link target text, not as actual symlinks. So no extra symlink + guard is needed for the stdlib path. + """ dest_resolved = dest.resolve() + total_uncompressed = 0 for member in zf.infolist(): - # Reject absolute paths and any traversal segments. + # Path traversal. member_path = Path(member.filename) if member_path.is_absolute() or any(part == ".." for part in member_path.parts): raise HTTPException(status_code=422, detail="zip_unsafe_path") @@ -264,6 +335,19 @@ def _safe_zip_extract(zf: zipfile.ZipFile, dest: Path) -> None: target.relative_to(dest_resolved) except ValueError: raise HTTPException(status_code=422, detail="zip_unsafe_path") + + # Decompression bomb. We sum declared sizes — these are advisory + # (an attacker can lie) but mismatched values trip Python's own + # CRC/size check during read. The pre-extract sum catches the + # honest-malicious case (large declared sizes) and is the last + # cheap fence before extractall touches the disk. + total_uncompressed += int(member.file_size or 0) + if total_uncompressed > MAX_ZIP_UNCOMPRESSED: + raise HTTPException( + status_code=413, + detail=f"zip_too_large_uncompressed (max {MAX_ZIP_UNCOMPRESSED // 1024 // 1024}MB)", + ) + zf.extractall(dest) @@ -729,6 +813,8 @@ async def create_entity( if category and not is_valid_category(category): raise HTTPException(status_code=400, detail="invalid_category") + video_url = _validate_video_url(video_url) + # Stream + extract ZIP into a scratch dir. tmp, size = await _stream_to_temp(file, MAX_ZIP_SIZE, suffix=".zip") try: @@ -757,6 +843,14 @@ async def create_entity( entity_id = uuid.uuid4().hex suffixed = suffixed_name(final_name, username) + # Global cross-owner check — sanitize_username is many-to-one, so + # two emails (alice.smith / alice_smith) can resolve to the same + # username and produce the same `-by-` suffix even + # when the per-owner UNIQUE passes. The suffixed value drives both + # the bundle on-disk dir and the served plugin.json `name`, so a + # collision silently last-write-wins. Refuse upfront. + if _suffixed_already_taken(conn, suffixed): + raise HTTPException(status_code=409, detail="conflict_global_suffix") plugin_dir = _plugin_dir(entity_id) file_size = _bake_plugin_tree( type_=type, @@ -820,12 +914,14 @@ async def update_entity( entity = repo.get(entity_id) if not entity: raise HTTPException(status_code=404, detail="entity_not_found") - if entity["owner_user_id"] != user["id"]: + if entity["owner_user_id"] != user["id"] and not is_user_admin(user["id"], conn): raise HTTPException(status_code=403, detail="not_owner") if category and not is_valid_category(category): raise HTTPException(status_code=400, detail="invalid_category") + video_url = _validate_video_url(video_url) + new_version: Optional[str] = None new_size: Optional[int] = None if file is not None: diff --git a/app/web/router.py b/app/web/router.py index e10f056..c3b6eeb 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -869,6 +869,7 @@ async def store_detail( from src.repositories.user_store_installs import UserStoreInstallsRepository from src.store_naming import suffixed_name from app.utils import get_store_dir + from app.auth.access import is_user_admin entity = StoreEntitiesRepository(conn).get(entity_id) if not entity: @@ -897,6 +898,10 @@ async def store_detail( user["id"], entity_id ) is_owner = entity["owner_user_id"] == user["id"] + # Admin can also Edit/Delete (parity with the API: store.py guards both + # mutations on owner OR admin). Without this the store_detail buttons + # would be hidden from admin even though they have authority. + is_admin = is_user_admin(user["id"], conn) ctx = _build_context( request, @@ -907,6 +912,7 @@ async def store_detail( files=files, is_installed=is_installed, is_owner=is_owner, + is_admin=is_admin, ) return templates.TemplateResponse(request, "store_detail.html", ctx) diff --git a/app/web/templates/store_detail.html b/app/web/templates/store_detail.html index dd4acc0..292ec88 100644 --- a/app/web/templates/store_detail.html +++ b/app/web/templates/store_detail.html @@ -157,7 +157,7 @@
- {% if is_owner %} + {% if is_owner or is_admin %}
Edit (coming soon) diff --git a/src/store_naming.py b/src/store_naming.py index 05d6601..2c8e081 100644 --- a/src/store_naming.py +++ b/src/store_naming.py @@ -36,6 +36,16 @@ def sanitize_username(email: str) -> str: Raises ``ValueError`` if the local-part sanitizes to an empty string — callers (the upload endpoint) translate that to a 400. + + Note: this mapping is **many-to-one** — ``alice.smith@x`` and + ``alice_smith@x`` both yield ``alice-smith``. The Store namespace is + flat in Claude Code, so two such users uploading entities with the + same display name would produce identical ``-by-`` + suffixes and collide in the served marketplace + bundle. The upload + endpoint enforces global uniqueness on the suffixed value via + ``app.api.store._suffixed_already_taken`` and rejects the second one + with 409 ``conflict_global_suffix``; the per-owner UNIQUE on + ``store_entities(owner_user_id, name)`` alone does not catch this. """ local = email.split("@", 1)[0].lower() s = _SANITIZE_RE.sub("-", local) diff --git a/tests/test_store_api.py b/tests/test_store_api.py index dddd044..3a19efe 100644 --- a/tests/test_store_api.py +++ b/tests/test_store_api.py @@ -377,6 +377,231 @@ class TestStoreUpload: assert r.json()["detail"] in {"zip_looks_like_plugin", "zip_looks_like_skill"} +class TestStoreSecurityFixes: + """Regression tests for the three security blockers and one correctness + bug found in PR #180 review (F1, F2, F4, F5).""" + + def test_video_url_javascript_scheme_rejected_on_create(self, web_client): + """F1 — `javascript:` URI must not be stored. Otherwise a malicious + uploader can pop XSS in any viewer's session via the + store_detail "Watch video" link.""" + _, cookies = _create_user(web_client, "f1a@x.com") + r = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("vid1"), "application/zip")}, + data={"type": "skill", "video_url": "javascript:alert(1)"}, + cookies=cookies, + ) + assert r.status_code == 400, r.text + assert r.json()["detail"] == "invalid_video_url" + + def test_video_url_data_scheme_rejected(self, web_client): + _, cookies = _create_user(web_client, "f1b@x.com") + r = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("vid2"), "application/zip")}, + data={"type": "skill", "video_url": "data:text/html,"}, + cookies=cookies, + ) + assert r.status_code == 400 + assert r.json()["detail"] == "invalid_video_url" + + def test_video_url_https_accepted(self, web_client): + _, cookies = _create_user(web_client, "f1c@x.com") + r = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("vid3"), "application/zip")}, + data={"type": "skill", "video_url": "https://www.youtube.com/watch?v=abc"}, + cookies=cookies, + ) + assert r.status_code == 201, r.text + assert r.json()["video_url"] == "https://www.youtube.com/watch?v=abc" + + def test_video_url_javascript_scheme_rejected_on_update(self, web_client): + _, cookies = _create_user(web_client, "f1d@x.com") + c = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("vid4"), "application/zip")}, + data={"type": "skill"}, cookies=cookies, + ) + eid = c.json()["id"] + u = web_client.put( + f"/api/store/entities/{eid}", + data={"video_url": "javascript:alert(1)"}, + cookies=cookies, + ) + assert u.status_code == 400 + assert u.json()["detail"] == "invalid_video_url" + + def test_zip_bomb_uncompressed_size_rejected(self, tmp_path): + """F2 — _safe_zip_extract must refuse when the sum of declared + file_size across infolist() exceeds MAX_ZIP_UNCOMPRESSED, BEFORE + extractall touches disk. + + We test the helper directly because Python's ``ZipFile.writestr`` + rewrites ``ZipInfo.file_size`` to the real payload length, making + an end-to-end ZIP-with-fake-size impossible without manual header + surgery. The bomb defense is in ``_safe_zip_extract``, so target + it directly with a stub ZipFile whose ``infolist()`` returns + entries with inflated declared sizes. + """ + from fastapi import HTTPException + + from app.api import store as store_module + + class _FakeZipFile: + def __init__(self, infolist): + self._infolist = infolist + self.extracted = False + + def infolist(self): + return self._infolist + + def extractall(self, dest): + # Must not be reached — the guard is supposed to raise + # before extractall. Mark and let the caller assert. + self.extracted = True + + zi = zipfile.ZipInfo("code-review/SKILL.md") + zi.file_size = store_module.MAX_ZIP_UNCOMPRESSED + 1 + zf = _FakeZipFile([zi]) + + try: + store_module._safe_zip_extract(zf, tmp_path) + except HTTPException as exc: + assert exc.status_code == 413 + assert "zip_too_large_uncompressed" in str(exc.detail) + else: + raise AssertionError("expected HTTPException 413, got none") + assert zf.extracted is False, "guard fired AFTER extractall — bug in fix" + + def test_admin_can_update_non_owned_entity(self, web_client): + """F4 — UPDATE must permit owner OR admin (parity with DELETE).""" + from argon2 import PasswordHasher + from src.db import get_system_db + from src.repositories.users import UserRepository + from tests.helpers.auth import grant_admin + + owner_id, owner_cookies = _create_user(web_client, "owner-f4@x.com") + c = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("f4-skill"), "application/zip")}, + data={"type": "skill"}, cookies=owner_cookies, + ) + eid = c.json()["id"] + + ph = PasswordHasher() + conn = get_system_db() + UserRepository(conn).create( + id="adm-f4", email="adm-f4@x.com", name="adm", + password_hash=ph.hash("AdminPass1!"), + ) + grant_admin(conn, "adm-f4") + admin_token = web_client.post( + "/auth/token", json={"email": "adm-f4@x.com", "password": "AdminPass1!"} + ).json()["access_token"] + admin_cookies = {"access_token": admin_token} + + u = web_client.put( + f"/api/store/entities/{eid}", + data={"description": "moderated by admin"}, + cookies=admin_cookies, + ) + assert u.status_code == 200, u.text + assert u.json()["description"] == "moderated by admin" + + def test_non_owner_non_admin_cannot_update(self, web_client): + """F4 negative — random user still gets 403 on UPDATE.""" + _, owner_cookies = _create_user(web_client, "owner-f4b@x.com") + c = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("f4b-skill"), "application/zip")}, + data={"type": "skill"}, cookies=owner_cookies, + ) + eid = c.json()["id"] + _, intruder_cookies = _create_user(web_client, "intruder-f4@x.com") + u = web_client.put( + f"/api/store/entities/{eid}", + data={"description": "hijack"}, + cookies=intruder_cookies, + ) + assert u.status_code == 403 + assert u.json()["detail"] == "not_owner" + + def test_admin_sees_action_buttons_on_store_detail(self, web_client): + """F4 — admin must see Edit/Delete in store_detail UI even when + not the owner.""" + from argon2 import PasswordHasher + from src.db import get_system_db + from src.repositories.users import UserRepository + from tests.helpers.auth import grant_admin + + _, owner_cookies = _create_user(web_client, "owner-ui@x.com") + c = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("ui-skill"), "application/zip")}, + data={"type": "skill"}, cookies=owner_cookies, + ) + eid = c.json()["id"] + + ph = PasswordHasher() + conn = get_system_db() + UserRepository(conn).create( + id="adm-ui", email="adm-ui@x.com", name="adm", + password_hash=ph.hash("AdminPass1!"), + ) + grant_admin(conn, "adm-ui") + admin_token = web_client.post( + "/auth/token", json={"email": "adm-ui@x.com", "password": "AdminPass1!"} + ).json()["access_token"] + admin_cookies = {"access_token": admin_token} + + page = web_client.get(f"/store/{eid}", cookies=admin_cookies) + assert page.status_code == 200, page.text + # Admin-non-owner sees the owner-actions panel — pre-fix, the + # `is_owner` gate hid it. Same gate now reads `is_owner or + # is_admin`. + assert "owner-actions" in page.text + assert 'id="delete-btn"' in page.text + + def test_cross_owner_suffix_collision_rejected(self, web_client): + """F5 — two emails can sanitize to the same username + (alice.smith / alice_smith → alice-smith). Both uploading a skill + called `code-review` would yield the same `code-review-by-alice-smith` + and silently collide in the served bundle + manifest. The upload + endpoint must refuse the second one.""" + _, a_cookies = _create_user(web_client, "alice.smith@x.com") + r1 = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("collide"), "application/zip")}, + data={"type": "skill"}, cookies=a_cookies, + ) + assert r1.status_code == 201, r1.text + assert r1.json()["invocation_name"] == "collide-by-alice-smith" + + _, b_cookies = _create_user(web_client, "alice_smith@x.com") + r2 = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip("collide"), "application/zip")}, + data={"type": "skill"}, cookies=b_cookies, + ) + assert r2.status_code == 409, r2.text + assert r2.json()["detail"] == "conflict_global_suffix" + + def test_distinct_suffixes_pass(self, web_client): + """F5 — uploads that yield distinct suffixed names must pass. (Avoid + regressing into rejecting all distinct uploads.)""" + _, a_cookies = _create_user(web_client, "alice@x.com") + _, b_cookies = _create_user(web_client, "bob@x.com") + for cookies, skill_name in [(a_cookies, "alpha"), (b_cookies, "beta")]: + r = web_client.post( + "/api/store/entities", + files={"file": ("s.zip", _make_skill_zip(skill_name), "application/zip")}, + data={"type": "skill"}, cookies=cookies, + ) + assert r.status_code == 201, r.text + + class TestInstallCycle: def test_install_uninstall_and_count(self, web_client): # Owner uploads, two other users install, install_count = 2.