From a04b8e73c966ea9b2562a658b42f164b06f82a47 Mon Sep 17 00:00:00 2001 From: lequangsang01 Date: Fri, 3 Jul 2026 20:33:31 +0700 Subject: [PATCH 1/6] fix(client): separate read/write auth in chain client (closes #6624) --- tests/test_vintage_ai_rustchain_client.py | 157 ++++++++++++++++++ vintage_ai_video_pipeline/rustchain_client.py | 105 ++++++++++-- 2 files changed, 244 insertions(+), 18 deletions(-) diff --git a/tests/test_vintage_ai_rustchain_client.py b/tests/test_vintage_ai_rustchain_client.py index 2e24128a3..9ca460e51 100644 --- a/tests/test_vintage_ai_rustchain_client.py +++ b/tests/test_vintage_ai_rustchain_client.py @@ -138,3 +138,160 @@ def read(self): return body return FakeResp() + + +# --- Issue #6624: _request_public must NOT include admin key headers --- + + +def test_request_public_uses_public_headers(monkeypatch): + """_request_public must use _get_public_headers (no admin key).""" + module = load_client_module() + client = module.RustChainClient( + base_url="https://node.example", admin_key="secret-admin-key-123" + ) + + captured_headers = {} + + class FakeResp: + def __enter__(self): + return self + def __exit__(self, *args): + return False + def read(self): + return b'{"ok": true}' + + def fake_urlopen(req, **kwargs): + captured_headers.update(req.headers) + return FakeResp() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + result = client._request_public("GET", "/health") + assert result == {"ok": True} + assert "X-Admin-Key" not in captured_headers, ( + f"_request_public must not send admin key; got headers: {captured_headers}" + ) + assert captured_headers.get("Accept") == "application/json" + + +def test_request_with_admin_key_sends_header(monkeypatch): + """_request (authenticated) must include X-Admin-Key when configured.""" + module = load_client_module() + client = module.RustChainClient( + base_url="https://node.example", admin_key="secret-admin-key-123" + ) + + captured_headers = {} + + class FakeResp: + def __enter__(self): + return self + def __exit__(self, *args): + return False + def read(self): + return b'{"ok": true}' + + def fake_urlopen(req, **kwargs): + captured_headers.update(req.headers) + return FakeResp() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + result = client._request("POST", "/api/submit", data={"key": "val"}) + assert result == {"ok": True} + assert captured_headers.get("X-Admin-Key") == "secret-admin-key-123" + + +def test_read_methods_use_public_no_admin_key(monkeypatch): + """Read methods (health, get_epoch, get_miners, etc.) must not send admin key.""" + module = load_client_module() + client = module.RustChainClient( + base_url="https://node.example", admin_key="secret-admin-key-123" + ) + + captured_headers = {} + + class FakeResp: + def __enter__(self): + return self + def __exit__(self, *args): + return False + def read(self): + return b'{"result": "ok"}' + + def fake_urlopen(req, **kwargs): + captured_headers.clear() + captured_headers.update(req.headers) + return FakeResp() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + read_endpoints = [ + lambda: client.health(), + lambda: client.get_epoch(), + lambda: client.get_wallet_balance("miner_123"), + lambda: client.get_wallet_history("miner_123"), + lambda: client.get_stats(), + lambda: client.get_hall_of_fame(), + lambda: client.get_miner_eligibility("miner_123"), + ] + + for call in read_endpoints: + call() + assert "X-Admin-Key" not in captured_headers, ( + f"Read method must not send admin key; got: {captured_headers}" + ) + + +def test_admin_key_not_set_no_header_sent(monkeypatch): + """When admin_key is None, no X-Admin-Key header is sent even on write requests.""" + module = load_client_module() + client = module.RustChainClient(base_url="https://node.example") + + captured_headers = {} + + class FakeResp: + def __enter__(self): + return self + def __exit__(self, *args): + return False + def read(self): + return b'{"ok": true}' + + def fake_urlopen(req, **kwargs): + captured_headers.update(req.headers) + return FakeResp() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + client._request("POST", "/api/submit", data={"key": "val"}) + assert "X-Admin-Key" not in captured_headers + + +def test_get_public_headers_never_includes_admin_key(): + """_get_public_headers() must never include admin key regardless of config.""" + module = load_client_module() + client = module.RustChainClient( + base_url="https://node.example", admin_key="super-secret" + ) + headers = client._get_public_headers() + assert "X-Admin-Key" not in headers + assert "Accept" in headers + + +def test_get_headers_includes_admin_key_when_set(): + """_get_headers() includes admin key when configured.""" + module = load_client_module() + client = module.RustChainClient( + base_url="https://node.example", admin_key="my-admin-key" + ) + headers = client._get_headers() + assert headers["X-Admin-Key"] == "my-admin-key" + + +def test_get_headers_no_admin_key_when_unset(): + """_get_headers() omits admin key when not configured.""" + module = load_client_module() + client = module.RustChainClient(base_url="https://node.example") + headers = client._get_headers() + assert "X-Admin-Key" not in headers diff --git a/vintage_ai_video_pipeline/rustchain_client.py b/vintage_ai_video_pipeline/rustchain_client.py index 86e7668b7..411495349 100644 --- a/vintage_ai_video_pipeline/rustchain_client.py +++ b/vintage_ai_video_pipeline/rustchain_client.py @@ -28,6 +28,7 @@ class RustChainClient: def __init__( self, base_url: str = DEFAULT_BASE_URL, + admin_key: Optional[str] = None, verify_ssl: bool = False, timeout: int = 30, retry_count: int = 3, @@ -38,12 +39,14 @@ def __init__( Args: base_url: Base URL of the RustChain API + admin_key: Admin authentication key (used only for write operations) verify_ssl: Enable SSL verification (default: False for self-signed certs) timeout: Request timeout in seconds retry_count: Number of retries on failure retry_delay: Delay between retries (seconds) """ self.base_url = base_url.rstrip("/") + self.admin_key = admin_key self.verify_ssl = verify_ssl self.timeout = timeout self.retry_count = retry_count @@ -60,7 +63,17 @@ def __init__( self._known_miners = {} def _get_headers(self) -> Dict[str, str]: - """Get request headers""" + """Get request headers (includes admin key for write operations)""" + headers = { + "Accept": "application/json", + "User-Agent": "vintage-ai-video-pipeline/1.0.0", + } + if self.admin_key: + headers["X-Admin-Key"] = self.admin_key + return headers + + def _get_public_headers(self) -> Dict[str, str]: + """Get request headers WITHOUT admin key (for read-only operations)""" return { "Accept": "application/json", "User-Agent": "vintage-ai-video-pipeline/1.0.0", @@ -124,6 +137,62 @@ def _request( raise Exception("Max retries exceeded") + def _request_public( + self, + method: str, + endpoint: str, + ) -> Dict[str, Any]: + """Make an HTTP request WITHOUT the admin key header (read-only operations). + + This ensures that read operations (GET requests for balances, miner lists, + etc.) never send admin credentials, following the principle of least privilege. + """ + url = f"{self.base_url}{endpoint}" + headers = self._get_public_headers() + + for attempt in range(self.retry_count): + try: + req = Request(url, headers=headers, method=method) + + with urllib.request.urlopen( + req, + context=self._ctx, + timeout=self.timeout + ) as response: + raw = response.read() + response_data = raw.decode("utf-8").strip() if raw else "" + if not response_data: + return {} + return json.loads(response_data) + + except HTTPError as e: + error_body = e.read().decode("utf-8") if e.fp else "" + if attempt == self.retry_count - 1: + raise Exception( + f"HTTP Error {e.code}: {e.reason} - {error_body}" + ) + except URLError as e: + if attempt == self.retry_count - 1: + raise Exception(f"Connection Error: {e.reason}") + except json.JSONDecodeError as e: + if attempt == self.retry_count - 1: + raise Exception(f"Invalid JSON response: {str(e)}") + except Exception: + if attempt == self.retry_count - 1: + raise + + if attempt < self.retry_count - 1: + time.sleep(self.retry_delay * (attempt + 1)) + + raise Exception("Max retries exceeded") + + def _get_public(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: + """GET request without admin key (read-only operations)""" + if params: + query = urllib.parse.urlencode(params) + endpoint = f"{endpoint}?{query}" + return self._request_public("GET", endpoint) + def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: """GET request with query parameters""" if params: @@ -132,21 +201,21 @@ def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: return self._request("GET", endpoint) def health(self) -> Dict[str, Any]: - """Check node health""" - return self._get("/health") + """Check node health (public, no admin key)""" + return self._get_public("/health") def get_epoch(self) -> Dict[str, Any]: - """Get current epoch information""" - return self._get("/epoch") + """Get current epoch information (public, no admin key)""" + return self._get_public("/epoch") def get_miners(self) -> List[Dict[str, Any]]: """ - List all active miners - + List all active miners (public, no admin key) + Returns: List of miner information dictionaries """ - data = self._get("/api/miners") + data = self._get_public("/api/miners") if isinstance(data, list): return data if isinstance(data, dict): @@ -157,24 +226,24 @@ def get_miners(self) -> List[Dict[str, Any]]: return [] def get_miner_eligibility(self, miner_id: str) -> Dict[str, Any]: - """Check miner's epoch eligibility""" - return self._get("/lottery/eligibility", params={"miner_id": miner_id}) + """Check miner's epoch eligibility (public, no admin key)""" + return self._get_public("/lottery/eligibility", params={"miner_id": miner_id}) def get_wallet_balance(self, miner_id: str) -> Dict[str, Any]: - """Get wallet balance for a miner""" - return self._get("/wallet/balance", params={"miner_id": miner_id}) + """Get wallet balance for a miner (public, no admin key)""" + return self._get_public("/wallet/balance", params={"miner_id": miner_id}) def get_wallet_history(self, miner_id: str, limit: int = 10) -> Dict[str, Any]: - """Get transaction history for a miner""" - return self._get("/wallet/history", params={"miner_id": miner_id, "limit": limit}) + """Get transaction history for a miner (public, no admin key)""" + return self._get_public("/wallet/history", params={"miner_id": miner_id, "limit": limit}) def get_stats(self) -> Dict[str, Any]: - """Get network statistics""" - return self._get("/api/stats") + """Get network statistics (public, no admin key)""" + return self._get_public("/api/stats") def get_hall_of_fame(self) -> Dict[str, Any]: - """Get Hall of Fame leaderboard""" - return self._get("/api/hall_of_fame") + """Get Hall of Fame leaderboard (public, no admin key)""" + return self._get_public("/api/hall_of_fame") def monitor_attestations( self, From 2968e6801a29a61915652f2df94c21fb2eb545f4 Mon Sep 17 00:00:00 2001 From: lequangsang01 Date: Fri, 3 Jul 2026 20:45:49 +0700 Subject: [PATCH 2/6] test(client): mock HTTP requests in vintage AI client tests - Patch _get_public instead of _get in get_miners tests (client now uses _get_public for reads) - Use case-insensitive header matching for admin key test (urllib normalizes header names) --- tests/test_vintage_ai_rustchain_client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_vintage_ai_rustchain_client.py b/tests/test_vintage_ai_rustchain_client.py index 9ca460e51..1fea0d27b 100644 --- a/tests/test_vintage_ai_rustchain_client.py +++ b/tests/test_vintage_ai_rustchain_client.py @@ -20,8 +20,8 @@ def test_get_miners_accepts_envelope_payloads(monkeypatch): monkeypatch.setattr( client, - "_get", - lambda endpoint: { + "_get_public", + lambda endpoint, params=None: { "items": [ {"miner": "alice", "hardware_type": "PowerPC G4"}, {"miner": "bob", "hardware_type": "x86-64"}, @@ -40,7 +40,7 @@ def test_get_miners_returns_empty_list_for_unexpected_payload(monkeypatch): module = load_client_module() client = module.RustChainClient(base_url="https://node.example") - monkeypatch.setattr(client, "_get", lambda endpoint: {"pagination": {"total": 0}}) + monkeypatch.setattr(client, "_get_public", lambda endpoint, params=None: {"pagination": {"total": 0}}) assert client.get_miners() == [] @@ -199,7 +199,8 @@ def fake_urlopen(req, **kwargs): result = client._request("POST", "/api/submit", data={"key": "val"}) assert result == {"ok": True} - assert captured_headers.get("X-Admin-Key") == "secret-admin-key-123" + headers_lower = {k.lower(): v for k, v in captured_headers.items()} + assert headers_lower.get("x-admin-key") == "secret-admin-key-123" def test_read_methods_use_public_no_admin_key(monkeypatch): From 29f616a72ccda129663b2e522ccb3b2aad8393b3 Mon Sep 17 00:00:00 2001 From: lequangsang01 Date: Fri, 3 Jul 2026 22:59:46 +0700 Subject: [PATCH 3/6] fix(bcos-badge): replace innerHTML with DOM construction to prevent XSS (closes #7137) --- tools/bcos-badge-generator/index.html | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tools/bcos-badge-generator/index.html b/tools/bcos-badge-generator/index.html index f11d8bb0e..1b31f6a8f 100644 --- a/tools/bcos-badge-generator/index.html +++ b/tools/bcos-badge-generator/index.html @@ -732,14 +732,14 @@

BCOS Badge Generator

img.alt = `BCOS Badge for ${certId}`; img.onload = () => { - previewArea.innerHTML = ''; + previewArea.replaceChildren(); previewArea.appendChild(img); resolve(); }; img.onerror = () => { // For demo purposes, show a placeholder if endpoint is unavailable - previewArea.innerHTML = ''; + previewArea.replaceChildren(); const container = document.createElement('div'); container.style.textAlign = 'left'; @@ -808,7 +808,11 @@

BCOS Badge Generator

function setLoading(loading) { if (loading) { generateBtn.disabled = true; - generateBtn.innerHTML = 'Generating...'; + generateBtn.replaceChildren(); + const spinner = document.createElement('span'); + spinner.className = 'spinner'; + generateBtn.appendChild(spinner); + generateBtn.appendChild(document.createTextNode('Generating...')); } else { generateBtn.disabled = false; generateBtn.textContent = 'Generate Badge'; @@ -821,7 +825,7 @@

BCOS Badge Generator

currentStyle = 'flat'; styleBtns.forEach(b => b.classList.remove('active')); styleBtns[0].classList.add('active'); - previewArea.innerHTML = ''; + previewArea.replaceChildren(); const span = document.createElement('span'); span.className = 'preview-placeholder'; span.textContent = 'Enter a Certificate ID and click "Generate Preview" to see your badge'; From 3b157faa301f2be3a0650b8d9825bbfff8768c7e Mon Sep 17 00:00:00 2001 From: lequangsang01 Date: Fri, 3 Jul 2026 23:02:53 +0700 Subject: [PATCH 4/6] fix(faucet): replace innerHTML with textContent to prevent DOM XSS (closes #7160) --- faucet_service/faucet_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/faucet_service/faucet_service.py b/faucet_service/faucet_service.py index 9b41f18a0..7fe15083e 100644 --- a/faucet_service/faucet_service.py +++ b/faucet_service/faucet_service.py @@ -1595,7 +1595,7 @@ def get_template_vars(config: Dict) -> Dict: submitBtn.disabled = true; submitBtn.textContent = 'Processing...'; result.className = 'result'; - result.innerHTML = ''; + result.textContent = ''; const wallet = walletInput.value.trim(); @@ -1611,7 +1611,7 @@ def get_template_vars(config: Dict) -> Dict: result.className = 'result show ' + (data.ok ? 'success' : 'error'); if (data.ok) { - result.innerHTML = ''; + result.textContent = ''; const strong = document.createElement('strong'); strong.textContent = '✅ Success!'; result.appendChild(strong); @@ -1627,7 +1627,7 @@ def get_template_vars(config: Dict) -> Dict: walletInput.value = ''; loadStats(); } else { - result.innerHTML = ''; + result.textContent = ''; const strong = document.createElement('strong'); strong.textContent = `❌ ${data.error}`; result.appendChild(strong); @@ -1640,7 +1640,7 @@ def get_template_vars(config: Dict) -> Dict: } } catch (err) { result.className = 'result show error'; - result.innerHTML = ''; + result.textContent = ''; const strong = document.createElement('strong'); strong.textContent = '❌ Error: '; result.appendChild(strong); From 6e408c092350cacffa0ba3a039b92b2930c27008 Mon Sep 17 00:00:00 2001 From: lequangsang01 Date: Fri, 3 Jul 2026 23:09:37 +0700 Subject: [PATCH 5/6] fix(docs): update Beacon Atlas endpoint from broken /relay/discover to /beacon/atlas (closes #7794) --- site/beacon/advertise.js | 2 +- site/beacon/data.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/beacon/advertise.js b/site/beacon/advertise.js index 4f7b1d964..1e4910476 100644 --- a/site/beacon/advertise.js +++ b/site/beacon/advertise.js @@ -46,7 +46,7 @@ const LISTING_TIERS = [ benefits: [ 'Your agent appears as a permanent node on the 3D Atlas', 'Custom city placement based on your agent capabilities', - 'Listed in /relay/discover API for cross-agent collaboration', + 'Listed in /beacon/atlas for cross-agent collaboration', 'Reputation score tracking and bounty eligibility', 'Featured in "Integrated Partners" section', 'Access to Beacon contract and mayday systems', diff --git a/site/beacon/data.js b/site/beacon/data.js index e8da82227..aa071152f 100644 --- a/site/beacon/data.js +++ b/site/beacon/data.js @@ -584,7 +584,7 @@ export async function fetchAllAgents(apiBase) { // --- 2. Beacon relay agents --- try { - const resp = await fetch(`${apiBase}/relay/discover`); + const resp = await fetch(`${apiBase}/beacon/atlas`); if (resp.ok) { const relays = await resp.json(); for (const ra of relays) { From dbbcb8bfc6ec44763e201b03f1dd6c5463f8d355 Mon Sep 17 00:00:00 2001 From: lequangsang01 Date: Fri, 3 Jul 2026 23:12:27 +0700 Subject: [PATCH 6/6] fix(docs): update Beacon Atlas endpoint from broken /beacon/api/relay/discover (closes #7794) --- node/beacon_api.py | 8 +++++--- site/beacon/data.js | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/node/beacon_api.py b/node/beacon_api.py index 95c0d0920..7c2f3fb41 100644 --- a/node/beacon_api.py +++ b/node/beacon_api.py @@ -1368,9 +1368,11 @@ def chat(): @beacon_api.route('/relay/discover', methods=['GET']) def relay_discover(): - """Discover relay agents (for 3D visualization).""" - # In production, query the relay registry - # For demo, return empty array + """Discover relay agents (for 3D visualization). + + .. deprecated:: + Use /beacon/atlas instead. This endpoint returns an empty array. + """ return jsonify([]) diff --git a/site/beacon/data.js b/site/beacon/data.js index aa071152f..a3815a0c2 100644 --- a/site/beacon/data.js +++ b/site/beacon/data.js @@ -586,7 +586,8 @@ export async function fetchAllAgents(apiBase) { try { const resp = await fetch(`${apiBase}/beacon/atlas`); if (resp.ok) { - const relays = await resp.json(); + const atlasData = await resp.json(); + const relays = atlasData.agents || atlasData; for (const ra of relays) { const canonicalId = resolveFromAliasMap(aliasMap, ra.name, ra.model_id, ra.agent_id); if (canonicalId && agentMap.has(canonicalId)) {