mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
This commit is contained in:
@@ -1,9 +1,36 @@
|
||||
"""Integration tests for GitHub Backup API endpoints."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_private_repo_check():
|
||||
"""Default mock: test_connection returns success + confirmed private.
|
||||
|
||||
POST /config and PATCH /config now refuse to save when the target repo
|
||||
isn't confirmed private (Bambuddy backups carry credentials — see
|
||||
`_enforce_private_repo` in github_backup.py routes). The default mock
|
||||
here keeps the existing test suite green; tests that need to exercise
|
||||
the public / unknown-visibility branches override this fixture inline.
|
||||
"""
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": "test/repo",
|
||||
"permissions": {"push": True},
|
||||
"is_private": True,
|
||||
}
|
||||
),
|
||||
) as m:
|
||||
yield m
|
||||
|
||||
|
||||
class TestGitHubBackupConfigAPI:
|
||||
"""Integration tests for /api/v1/github-backup endpoints."""
|
||||
|
||||
@@ -234,6 +261,195 @@ class TestGitHubBackupConfigAPI:
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestGitHubBackupPrivateRepoGuard:
|
||||
"""Refuse to save a config when the target repository is not private.
|
||||
|
||||
Bambuddy backups contain MQTT credentials, HA/Prometheus tokens, the
|
||||
Bambu Cloud email, and printer access codes via K-profiles — they must
|
||||
never be pushed to a public or internal-visibility repository.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_config_rejects_public_repo(self, async_client: AsyncClient):
|
||||
"""POST /config returns 400 when the connection test reports is_private=False."""
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": "test/public-repo",
|
||||
"permissions": {"push": True},
|
||||
"is_private": False,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.post(
|
||||
"/api/v1/github-backup/config",
|
||||
json={
|
||||
"repository_url": "https://github.com/test/public-repo",
|
||||
"access_token": "ghp_token",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "not private" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_config_rejects_unknown_visibility(self, async_client: AsyncClient):
|
||||
"""POST /config returns 400 when is_private cannot be determined (None)."""
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": "test/repo",
|
||||
"permissions": {"push": True},
|
||||
"is_private": None,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.post(
|
||||
"/api/v1/github-backup/config",
|
||||
json={
|
||||
"repository_url": "https://github.com/test/repo",
|
||||
"access_token": "ghp_token",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "could not confirm" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_config_rejects_failed_connection(self, async_client: AsyncClient):
|
||||
"""POST /config returns 400 when the connection test itself fails."""
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": False,
|
||||
"message": "Invalid access token",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
"is_private": None,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.post(
|
||||
"/api/v1/github-backup/config",
|
||||
json={
|
||||
"repository_url": "https://github.com/test/repo",
|
||||
"access_token": "bad-token",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid access token" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_rejects_url_change_to_public_repo(self, async_client: AsyncClient):
|
||||
"""Changing the repository_url on an existing config re-checks privacy."""
|
||||
# Initial create succeeds via the default autouse mock (private).
|
||||
await async_client.post(
|
||||
"/api/v1/github-backup/config",
|
||||
json={
|
||||
"repository_url": "https://github.com/test/private-repo",
|
||||
"access_token": "ghp_token",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Now try to switch to a public repo — must be rejected.
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": "test/public-repo",
|
||||
"permissions": {"push": True},
|
||||
"is_private": False,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.patch(
|
||||
"/api/v1/github-backup/config",
|
||||
json={"repository_url": "https://github.com/test/public-repo"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "not private" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_patch_skips_check_for_unrelated_fields(self, async_client: AsyncClient):
|
||||
"""PATCHing a non-target field (e.g. schedule) does NOT re-run the test.
|
||||
|
||||
Without this, every benign toggle would trigger a live API call.
|
||||
"""
|
||||
await async_client.post(
|
||||
"/api/v1/github-backup/config",
|
||||
json={
|
||||
"repository_url": "https://github.com/test/private-repo",
|
||||
"access_token": "ghp_token",
|
||||
"branch": "main",
|
||||
"schedule_enabled": False,
|
||||
"schedule_type": "daily",
|
||||
"backup_kprofiles": True,
|
||||
"backup_cloud_profiles": True,
|
||||
"backup_settings": True,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Replace the mock with one that would fail if called — proves the
|
||||
# PATCH didn't hit test_connection for a schedule-only change.
|
||||
mock = AsyncMock(side_effect=AssertionError("test_connection should not be called"))
|
||||
with patch(
|
||||
"backend.app.services.github_backup.github_backup_service.test_connection",
|
||||
new=mock,
|
||||
):
|
||||
response = await async_client.patch(
|
||||
"/api/v1/github-backup/config",
|
||||
json={"schedule_enabled": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
class TestGitHubBackupStatusAPI:
|
||||
"""Integration tests for /api/v1/github-backup/status endpoint."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user